# Holistics Docs (4.0)
> Documentation for Holistics - Self-service BI Platform
This file contains all documentation content in a single document following the llmstxt.org standard.
## Holistics API
## Introduction
At Holistics, we treat API interface as a first-class citizen. Our goal is to allow you to flexibly integrate Holistics with your workflow as much as possible.
We do this by trying to design an extensive, easy to use API coupled with readily available libraries.
:::tip OpenAPI Specification
Download the [OpenAPI spec (YAML)](/api/v2.yml) to use with API clients, code generators, or AI agents.
:::
:::caution
**Holistics API version does not correlate with Holistics feature version**. For example, Holistics API v2 can support operations on Holistics 2.0, 2.7 and 3.0.
The latest API version is API v2.
API v1 and below is considered legacy API. Legacy API is functional but is not in active development/ maintenance anymore.
:::
## Use cases
These are some of the most common things teams reach for the API to do.
Programmatically export CSV or Excel data from any dashboard or chart, so you can pass live numbers to your other applications.
Kick off data schedule jobs from your own workflow, on your own triggers, instead of waiting for the next scheduled run.
List, fetch, clone, and manage dashboards. Set up email report delivery to your customers when a new account onboards.
Provision and deprovision users programmatically to keep access in sync with your own systems.
:::info
Our API is currently work-in-progress, so some of the use cases/features might not be available. If you have an API use case you want to support/prioritize, please reach out to our Support.
:::
## Data Center Regions
API data is limited to specific regions. You must use the correct subdomain for your data center region.
| Region | API Base URL |
|--------|--------------|
| Asia-Pacific (APAC) | `https://secure.holistics.io/api/v2` |
| Europe (EU) | `https://eu.holistics.io/api/v2` |
| United States (US) | `https://us.holistics.io/api/v2` |
:::tip Finding your region
Check the URL when you log into Holistics:
- `secure.holistics.io` → APAC
- `eu.holistics.io` → EU
- `us.holistics.io` → US
See [Data Centers](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on) for more details.
:::
## Authentication
All API requests require authentication using an API key passed in the `X-Holistics-Key` header.
```bash
curl -X GET \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Holistics-Key: YOUR_API_KEY" \
https://secure.holistics.io/api/v2/users/me
```
For step-by-step instructions on enabling API access, generating your API key, and making your first call, see the [Getting Started guide](/api/v2/getting-started).
---
## Jobs API
## What is Job?
Every time a user starts executing something in Holistics, a Job is created. It is then picked up and executed by
Holistics background workers. Learn more about our [jobs queue mechanism](/docs/jobs/queues-and-workers) here.
## Get Job info
```
GET /jobs/:job_id.json
```
Parameters:
- `job_id`: Integer. Job ID
Response:
```json
{
"id": 1138,
"status": "success",
"created_at": "2018-08-24T08:19:19.596Z",
"source_id": 3,
"source_type": "DataImport",
"user_id": 3,
"start_time": "2018-08-24T08:19:20.670Z",
"end_time": "2018-08-24T08:19:20.924Z",
"tenant_id": 2,
"duration": 0.253676,
"cancellable": true,
"source_method": "execute"
}
```
## Last run jobs
Retrieve list of jobs last run.
```
GET /jobs/last_run_jobs.json
```
Parameters:
- source_type: Possible values: 'DataImport', 'DataTransform', 'EmailSchedule', 'ScheduleCache'
- ids: IDs of the jobs' sources. These sources must have the same type specified in param `source_type`
Response:
```json
{
"3": {
"id": 1138,
"status": "success",
"start_time": "2018-08-24T08:19:20Z",
"end_time": "2018-08-24T08:19:20Z",
"created_at": "2018-08-24T08:19:19Z"
}
}
```
---
## Group API
:::caution Warning
This is a **legacy API**. It is still functional but is **not in active development/ maintenance** anymore.
:::
These API endpoints allows the admin to manage their groups programmatically.
## How to use these API endpoints
### Obtain API Key
Before using the API, please obtain the API key by following the [Getting Started guide](/api/v2/getting-started).
### Send API requests to Holistics
To use these APIs, simply append Holistics' host URL `secure.holistics.io` with your chosen endpoint.
For example, to call the API [Get all users in a tenant with full information](/api/v1/user-api#get-all-users-in-a-tenant-with-full-information), you will have to use **GET** request with this URL: `secure.holistics.io/user.json`.
## Get all groups in a tenant
**Sample request:**
```
GET /groups.json
```
**Sample response:**
```json
[
{
"id": 1,
"name": "Singapore",
"num_user": 2
},
{
"id": 2,
"name": "Vietnam",
"num_user": 2
},
{
"id": 3,
"name": "Indonesia",
"num_user": 0
}
]
```
## Create a new group
**Sample request:**
```
POST /groups.json
```
**Sample body request:**
```json
{
"group":{
"name" : "Test"
}
}
```
**Sample success response:**
```json
{
"id": 1,
"name": "Test",
"created_at": "2018-09-07T09:18:43.742Z",
"updated_at": "2018-09-07T09:18:43.742Z",
"tenant_id": 5
}
```
## Update information of an existing group
**Sample request:**
```
PUT /groups/group_id.json
```
**Permitted params:**
- **name:** The name of your group
**Sample body request:**
```json
{
"group":{
"name" : "New Admin Group"
}
}
```
## Delete an existing group
**Sample request:**
```
DELETE /groups/group_id.json
```
## Add a user into a group
**Sample request:**
```
PUT /groups/group_id/user/user_id
```
Sample success response:
```json
{
"status": "OK"
}
```
## Remove a user from a group
**Sample request:**
```
DELETE /groups/34/user/454
```
**Sample success response:**
```json
{
"status": "OK"
}
```
---
## User API
:::caution Warning
This is a **legacy API**. It is still functional but is **not in active development/ maintenance** anymore.
:::
These API endpoints allows the admin to manage their users programmatically.
## How to use these API endpoints
### Obtain API Key
Before using the API, please obtain the API key by following the [Getting Started guide](/api/v2/getting-started).
### Send API requests to Holistics
To use these APIs, simply append Holistics' host URL `secure.holistics.io` with your chosen endpoint.
For example, to call the API [Get all users in a tenant with full information](#get-all-users-in-a-tenant-with-full-information), you will have to use **GET** request with this URL: `secure.holistics.io/user.json`.
## Get all users in a tenant with full information
**Sample request:**
```
GET /users.json
```
**Sample response:**
```
[
{
"id": 1,
"name": "Analyst",
"email": "analyst@holistics.io",
"role": "analyst",
"initials": "An",
"is_deleted": false,
"is_activated": true,
"has_authentication_token": true,
"groups": [
{
"id": 33,
"name": "Capital",
"created_at": "2015-06-29T03:22:14.842Z",
"updated_at": "2015-06-29T03:22:14.842Z",
"tenant_id": 5
}
],
"allow_authentication_token": true,
"current_sign_in_at": "2018-10-23T03:35:47.353Z",
"last_sign_in_at": "2018-10-22T03:21:52.393Z"
},
{
"id": 2,
"name": "Business User",
"email": "business@holistics.io",
"role": "business",
"initials": "Bu",
"is_deleted": false,
"is_activated": true,
"has_authentication_token": false,
"groups": [],
"allow_authentication_token": false,
"current_sign_in_at": "2018-10-21T03:35:47.353Z",
"last_sign_in_at": "2018-10-20T03:21:52.393Z"
},
]
```
:::note
- **current_sign_in_at:** latest sign-in timestamp.
- **last_sign_in_at:** previous sign-in timestamp (before the current_sign_in_at time).
- **allow_authentication_token** is used to determine whether a user **is allowed** for API access (only Admin can update this field for a specific user).
- **has_authentication_token** is used for determining whether a user **has already generated** an API access key (this flag will be turned to false if the Revoke Authentication Token call is requested).
- A **Tenant** is the organization that is using Holistics (e.g. Grab).
:::
## Invite a new user to Holistics
**Sample request:**
```
POST /users/invite.json
```
**Parameters:**
- **name:** user's full name
- **email:** user's email address
- **role:** user role. At Holistics, there are 4 roles: admin, analyst, explorer, viewer.To learn more about the role system, visit [User Roles](/docs/admin/user-roles).
- **message:** The invitation message that will be sent to the invitee.
**Sample request body:**
```
{
"name": "Test",
"email": "tester@tenant.com",
"role": "user",
"message": "Hey, let's join MyCompany workspace on Holistics"
}
```
**Sample success response:**
```
{
"status": "ok"
}
```
**Sample error response when a user already exists in Holistics's database:**
```
{
"errors": [
"Email already existed"
]
}
```
You will need to use the [Resend Invitation API](#resend-invitation-to-user) instead.
## Resend invitation to user
**Sample request:**
```
POST /users/user_id/resend_invite.json
```
**Sample success response:**
```json
{
"status": "ok"
}
```
## Soft-delete a user
**Sample request:**
```
DELETE /users/user_id.json
```
**Sample success response:**
```json
{
"status": "ok"
}
```
## Restore a deleted user
**Sample request:**
```
POST /users/restore.json
```
**Sample request body:**
```json
{
"id": 560
}
```
**Sample error response when attempt to restore a non-deleted user:**
```json
{
"errors": [
"User is not deleted"
]
}
```
## Allow/ Revoke a user's API access
**Sample request:**
```
PATCH /users/user_id.json
```
**Sample body request:**
```json
{
"allow_authentication_token": true
}
```
**Sample success response:**
```json
{
"status": "ok"
}
```
## Revoke Authentication Token from a user
This API is used to revoke generated Authentication Tokens. Consider using this API if:
- You are worried that the current token may have been leaked.
- You want to enforce a token refresh for security reasons.
After their token has been revoked, the user would need to re-generate a new token by following the [Getting Started guide](/api/v2/getting-started).
**Sample request:**
```
POST /users/user_id/revoke_authentication_token.json
```
**Sample success response:**
```json
{
"status": "ok"
}
```
## Check whether email address is already used for a user in Holistics
**Sample request:**
```
GET /users/check_holistics_user.json/?email={email_address}
```
**Sample success response:**
```json
{
"is_already_user": true
}
```
## Change user role in Holistics
**Required params:**
- **user_id**
- **user:** an object that contains the needed information for changing user role
- **role (string):** can be `admin`, `analyst` or `user`
- **remove_groups (boolean):** false by default. If the params are set to true, then the user's groups will also be removed after the role is changed.
**Sample request:**
```
POST /users/change_user_role.json
```
**Sample body request:**
```json
{
id: 1,
user: {
role: "analyst",
remove_groups: true
}
}
```
## Find user by email address
**Sample request:**
```
GET /users/get_user.json/?email={email_address}
```
**Sample success response:**
```json
{
"id": 1,
"name": "Business User",
"email": "business@holistics.io",
"role": "business",
"title": null
}
```
---
## Create Data Alerts using API
## Introduction
In Holistics, you can set up a system that sends you automatic notifications when data meets certain criteria, allowing you to make timely and strategic business decisions. This concept is called [**Data Alert**](/docs/delivery/data-alert).
Holistics offers a [set of public API endpoints](/api/v2/reference/data-alerts-create) allowing clients to work with Data Alert. This tutorial shows you how to programmatically create these data alerts using the API.
## The Use Case
Assuming you are an e-commerce company that wants to track the number and status of orders placed on your platform. You can set up data alerts that trigger notifications when certain conditions are met, such as:
1. **A number of orders placed in a specific time period exceed a certain threshold.**
2. **A certain percentage of orders are canceled or marked as "canceled".**
You can receive these alerts through email or Slack, and use this information to quickly respond to any issues or to make informed decisions about adjusting inventory or shipping processes.
Let's walk through how we can create a data alert to **track your number of orders using Holistics API**.
## High-level Approach
We will use the API to create email data alerts. Specifically, we'll send a POST request to [Holistics create data alert API](/api/v2/reference/data-alerts-create).
Before that, we need to prepare the following:
- A dashboard widget with relevant report
- Holistics API Key: We need to setup the API key. Refer to the [Getting Started guide](/api/v2/getting-started) for setup instructions.
## How to Create a Data Alert via API
### Step 1: Preparing the Dashboard Widget
You will need to create a dashboard widget containing the data of interest to you. In this example, I used this simple report that counts the total number of orders.

### Step 2: Making the API Call
We'll be using the following [endpoint](/api/v2/reference/data-alerts-create):
```
POST https://secure.holistics.io/api/v2/data_alerts
```
There are some notable configuration options in this request:
- `schedule`: Specify how frequently the email schedule should be
- `viz_conditions`: Specify what alert condition should be checked on the report result. By configuring this, we can customize the alert for each alerting use case.
- `dest`: Specify configurations regarding the alert delivery destination, e.g. the recipients' information.
- `dynamic_filter_presets`: Specify what dynamic filters should be applied to the report. By configuring this, we can customize the report for each recipient.
Below is a sample request body. Refer to [Holistics API docs](/api/v2/reference/data-alerts-create) for more information on these fields.

#### Dest field
- `dest`: Specify configurations regarding recipients' information
- `title` (string): Note that Holistics support [dynamic variables](/docs/delivery/email-schedules#data-schedule-dynamic-variables-support) in email title. For example, the title `Sales report for {{$today}}` sent on 29/09/2021 will be rendered as `Sales report for 2021-09-29 Wed`.
- `type`: This tells Holistics you want to receive notification via email: `EmailDest` or Slack: `SlackDest`
- If you choose `SlackDest`, you will need to [get the id and the name of your slack channel](https://help.socialintents.com/article/148-how-to-find-your-slack-team-id-and-slack-channel-id) for the `slack_channels` field
#### Viz conditions
- `viz_conditions`: Coming to the core of the alert, the condition to set data alert
- `field_path`: This is used to extract from a specific data model
- `field_name`: field name of the field in the data model you want to apply the condition.
- `model_id`: The id of the data model associated with the `field_name`
- You can get the `field_name` and `model_id` by following these steps:
- From the widget id, use [Get a dashboard widget](/api/v2/reference/dashboard-widgets-get) (include_report=true) then extract `query_report.viz_settings.fields`, you can find the field you want to add condition then extract `path_hash` to get `model_id` and `field_name`
- `aggregation`: The aggregation that will be applied on the field when processing the condition.
- `transformation`: The transformation that will be applied on the field when processing the condition.
- `condition`: be applied on the Data Modeling layer to filter your data.
- Example: you have a field `id` and want to check whether the number of it is greater than 35000
```json
{
"field_path": {
"field_name": "id",
"model_id": 1
},
"aggregation": "count",
"transformation": null,
"condition": {
"operator": "greater_than",
"modifier": null,
"values": [35000]
}
}
```
#### Schedule
`schedule`: This field is used to specify the schedule for your email schedule.
`repeat` (string): You will need to input a [crontab expression](https://crontab.guru/) to let Holistics know how frequently you want your email schedule to run.
#### Dashboard Filters
`dynamic-filter-presets` is where you define your filter presets.
`dynamic_filter_id`: To get your `filter_id`, send a request to [Dashboards#Get API](/api/v2/reference/dashboards-get) to get the dashboard info.
`preset_conditions`: Let's go through our example to better illustrate this field.
We want to set a value for the filter by setting a preset condition. **A preset condition consists of an operator and a list of values.** Holistics supports various operators.
In this case, we want to use operator `is` and set values to `customer_id`. By this, we mean the filter's default value equals exactly to `customer_id`.
Finally, we also want to set up another dynamic filter preset to filter data to the whole of last week. Fortunately, Holistics allows a list of dynamic filter presets. For the Date Range filter, we similarly want to use operator `is` and set values to `"last 7 days"`.
Our final `dynamic_filter_presets` object will look like this:
```js
dynamic_filter_presets = [
{
"dynamic_filter_id": MY_CUSTOMER_FILTER_ID,
"preset_condition": {
"operator": "is",
"values": [
customer_id
]
}
},
{
"dynamic_filter_id": MY_DATE_RANGE_FILTER_ID,
"preset_condition": {
"operator": "is",
"values": [
"last 7 days"
]
}
}
]
```
### Step 3: Verify to see if your data alert is created
The request should now be ready. After sending it to Holistics, the server will respond with a [HTTP status response code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) to indicate whether the request was successful / failed.
Lastly, be sure to head over to the Holistics page to see if your data alert is created.

## Example Script
An example script to send requests written in Ruby is available here:
```ruby
# install the neccessary dependencies for this script to work
require "bundler/inline"
require "net/http"
require "json"
gemfile do
source "https://rubygems.org"
gem "net-http"
end
# this the Holisitics API endpoint that we need to send our POST request to
DATA_ALERT_ENDPOINT = URI("https://secure.holistics.io/api/v2/data_alerts")
# Step 1: Create a POST request to the endpoint below
request = Net::HTTP::Post.new(DATA_ALERT_ENDPOINT)
# Step 2: Provide your authentication information
request["X-Holistics-Key"] = "mysecretAPIkey"
# Step 2.1: This line helps the system know that we are sending a JSON request
request.content_type = "application/json"
# Step 3: Provide information for your data alert in the request body
request.body = {
"data_alert": {
"title": "Tracking number of orders",
"source_id": 41,
"source_type": "DashboardWidget",
# Conditions for the Alert to be Triggered
"viz_conditions": [
{
"field_path": {
"field_name": "id",
"model_id": 12
},
"aggregation": "count",
"transformation": null,
"condition": {
"operator": "greater_than",
"modifier": null,
"values": [35000]
}
}
],
# Destination for the Alert
"dest": {
"type": "EmailDest",
"title": "The number of orders has just exceeded than 35000",
"recipients": [
"admin@domain.com"
],
"options": {
"body_text": null
}
},
# Schedule for the Alert to be Triggered
"schedule": {
# Repeat Everyday at 7:00 AM
"repeat": "0 7 * * *",
"paused": false
},
# Dynamic Filter Presets for the Alert
"dynamic_filter_presets": [
{
"preset_condition": {
"operator": "is",
"modifier": null,
"values": ["VN", "SG"],
},
"dynamic_filter_id": 1
},
{
"preset_condition": {
"operator": "is",
"modifier": null,
"values": [
"Male"
],
},
"dynamic_filter_id": 2
}
]
}
}.to_json
# Step 4: Send the request to Holistics!
response = Net::HTTP.start(
DATA_ALERT_ENDPOINT.hostname,
DATA_ALERT_ENDPOINT.port,
:use_ssl => DATA_ALERT_ENDPOINT.scheme == "https",
) do |https|
https.request(request)
end
# Optional: Output the result into the terminal
puts [response.code,response.message].join(' ')
```
---
## Create Email Schedules using API
## Introduction
In Holistics, you can set up schedules sending reports or dashboards to a group of recipients via email, Slack, SFTP, or Azure Blob. This concept is called **Data Schedule**.
Holistics offers a [set of public API endpoints](/api/v2/reference/data-schedules) allowing clients to work with Data Schedule. This tutorial shows you how to use the API to create these data schedules programmatically.
## The Use Case
Suppose you are a B2B SaaS company that has a feature to email usage statistics to your customers every week. You want to set it up such that:
- When a new customer onboards to your platform, you want **Holistics to send a weekly email report** to that customer.
- The weekly email reports **should be customised to display information regarding the usage data of that customer only**
Let's walk through how we can solve this problem using Holistics API.
## High-level Approach
We will use the API to create email reports. Specifically, we'll send a POST request to [Holistics' create email schedule API](/api/v2/reference/data-schedules-create).
Before that, we need to prepare the following:
- A dashboard with relevant reports. This dashboard will be emailed to the customer.
- The dashboard should have a "Customer" filter to filter down the data for a particular customer.
- Holistics API Key: We need to setup the API key. Refer to the [Getting Started guide](/api/v2/getting-started) for setup instructions.
## Step 1: Preparing the Dashboard
You will need to create a master dashboard that contains the reports you want to send to the customers. In this example, we have a `Customer Usage Data` dashboard which showcases the statistics of jobs that Holistics customers execute in our platform. We also have two filters in this dashboard, `Date Range` and `Customer`.
- The **Customer ID filter** accepts a customer ID, then passes it to the report within the dashboard. The report then replaces the ID with its placeholder in a prepared SQL query, reruns the query, and fetches only data that belongs to that customer. In other words, this filter helps **filter down to the usage data of that customer only**.
- Similarly, the **Date Range filter**'s value accepts `"last 7 days"`, `"last 14 days"`, `"last 30 days"`, or `"any time"`. This filter helps **filter usage data by a date range**. Note that this filter can be optional.
If you are not sure on how to create dashboards and filters, refer to [Dashboard Docs](/docs/dashboards).

## Step 2: Making the API Call
We'll be using the following [endpoint](/api/v2/reference/data-schedules-create):
```
POST https://secure.holistics.io/api/v2/data_schedules
```
There are some notable configuration options in this request:
- `schedule`: Specify how frequent the email schedule should be
- `dynamic_filter_presets`: Specify what dynamic filters should be applied to the reports. By configuring this, we can customise the report for each recipient.
- `dest`: Specify configurations regarding recipients' information.
- Remember to set `dest.type` to "EmailDest"
Below is a sample request body. Refer to [Holistics API docs](/api/v2/reference/data-schedules-create) for more information on these fields.

:::info
💡 Most of the fields are straightforward which means that you can fill them out by reading our API docs. Refer to this section below for notes on trickier fields.
:::
### Common fields
- `id` (integer): This is used to uniquely identified your object. Holistics automatically generates an id for you if you don't specify this.
- `source_id` (integer): This tells Holistics which dashboard you want to send email to. To determine this value, view your dashboard link. For example, the link [`https://secure.holistics.io/dashboards/v3/16076-demo-my-beautiful-dashboard`](https://secure.holistics.io/dashboards/v3/16076-demo-my-beautiful-dashboard) will have the `source_id` of 16076.
- `dest`: Specify the information regarding the recipients
- `title` (string): Note that Holistics support [dynamic variables](/docs/delivery/email-schedules#data-schedule-dynamic-variables-support) in email title. For example, the title `Sales report for {{$today}}` sent on 29/09/2021 will be rendered as `Sales report for 2021-09-29 Wed`.
### Schedule frequency
`schedule` is used to specify the schedule for your email schedule.
`repeat` (string): You will need to input a `crontab expression` here to let Holistics how frequent you want to your email schedule to run.
### Dynamic filter values
`dynamic-filter-presets` is where you define your filter presets here.
`dynamic_filter_id` (integer): To get your `filter_id`, send a GET request to [Dashboards#Get API](/api/v2/reference/dashboards-get) to get the dashboard info.
`preset_conditions` (object): Let's go through our example to better illustrate this field.
We want to set a value for the filter by setting a preset condition. **A preset condition consists of an operator and a list of values.** Holistics supports various operators and provides several examples in the [Data Schedules API](/api/v2/reference/data-schedules-create).
In this case, we want to use operator `is` and set values to `customer_id`. By this, we mean the filter's default value equals exactly to `customer_id`.
Finally, we also want to set up another dynamic filter preset to filter data to the whole of last week. Fortunately, Holistics allows a list of dynamic filter presets. For the Date Range filter, we similarly want to use operator `is` and set values to `"last 7 days"`.
Our final `dynamic_filter_presets` object will look like this.
```js
dynamic_filter_presets = [
{
"dynamic_filter_id": MY_CUSTOMER_FILTER_ID,
"preset_condition": {
"operator": "is",
"values": [
customer_id
]
}
},
{
"dynamic_filter_id": MY_DATE_RANGE_FILTER_ID,
"preset_condition": {
"operator": "is",
"values": [
"last 7 days"
]
}
}
]
```
## Step 3: Double-check to see if your email schedule is created
The request should now be ready. After sending it to Holistics, the server will respond with a [HTTP status response code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) to indicate whether the request was successful / failed.
Lastly, be sure to head over to Holistics page to see if your email schedule is created.

## Example script
An example script to send requests written in Ruby is available here:
```ruby
# install the neccessary dependencies for this script to work
require "bundler/inline"
require "net/http"
require "json"
gemfile do
source "https://rubygems.org"
gem "net-http"
end
# this the Holisitics API endpoint that we need to send our POST request to
EMAIL_SCHEDULE_ENDPOINT = URI("https://secure.holistics.io/api/v2/data_schedules")
# Step 1: Create a POST request to the endpoint below
request = Net::HTTP::Post.new(EMAIL_SCHEDULE_ENDPOINT)
# Step 2: Provide your authentication information
request["X-Holistics-Key"] = "mysecretAPIkey"
# Step 2.1: This line helps the system know that we are sending a JSON request
request.content_type = "application/json"
# Step 3: Provide information for your email schedule in the request body
request.body =
{
"data_schedule": {
"source_type": "Dashboard",
"source_id": 16076,
"schedule": {
"repeat": "* * * * *",
"paused": false,
},
"dest": {
"type": "EmailDest",
"title": "Sale report for {{$today}}",
"recipients": [
"abc@gmail.com",
],
},
},
}.to_json
# Step 4: Send the request to Holistics!
response = Net::HTTP.start(
EMAIL_SCHEDULE_ENDPOINT.hostname,
EMAIL_SCHEDULE_ENDPOINT.port,
:use_ssl => EMAIL_SCHEDULE_ENDPOINT.scheme == "https",
) do |https|
https.request(request)
end
# Optional: Output the result into the terminal
puts [response.code,response.message].join(' ')
```
---
## Error Handling
## HTTP Status Codes
| Code | Description |
|------|-------------|
| 200 | **OK** - Request succeeded |
| 400 | **Bad Request** - Missing parameter or invalid request structure |
| 401 | **Unauthorized** - Invalid or missing API key |
| 403 | **Forbidden** - API key owner lacks permission for this action |
| 404 | **Not Found** - Resource doesn't exist |
| 422 | **Unprocessable Entity** - Semantic errors in parameters |
| 429 | **Too Many Requests** - Rate limit exceeded (see [Rate Limiting](/api/v2/rate-limiting)) |
| 500 | **Internal Error** - Server-side issue (rare) |
## Error Types
All errors follow a consistent structure with a `type` field indicating the error category:
| Error Type | Description |
|------------|-------------|
| `BaseError` | Base schema for all error types |
| `RecordError` | Error when saving a record |
| `InvalidParameterError` | One or more parameters are missing or invalid |
| `AuthError` | Authentication failed (missing or expired token) |
| `MaintenanceError` | Holistics is in maintenance mode |
| `SubscriptionError` | Subscription lacks permission for this resource |
| `PermissionDeniedError` | API key owner lacks access to this resource |
| `InternalHolisticsError` | Internal error - provide `debug id` to support |
| `InvalidOperationError` | Action blocked by system constraints |
## Error Response Format
All error responses follow this structure:
```json
{
"error": {
"type": "InvalidParameterError",
"message": "Parameter 'source_id' is required",
"debug_id": "abc123xyz"
}
}
```
## Handling Errors
**Best practices:**
1. **Check status codes first** - Use HTTP status to determine error category
2. **Parse error type** - Use the `type` field for specific error handling
3. **Log debug IDs** - For `InternalHolisticsError`, save the `debug_id` for support requests
4. **Validate inputs** - Prevent `InvalidParameterError` by validating before sending
**Example error handling (Python):**
```python
response = requests.post(url, headers=headers, json=data)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
raise Exception("Invalid API key")
elif response.status_code == 403:
raise Exception("Permission denied")
elif response.status_code == 422:
error = response.json().get('error', {})
raise Exception(f"Validation error: {error.get('message')}")
elif response.status_code == 429:
# Handle rate limiting
pass
else:
error = response.json().get('error', {})
raise Exception(f"API error: {error.get('message')} (debug_id: {error.get('debug_id')})")
```
---
## API FAQs
## How do I submit array query parameters?
When a query parameter accepts an array, use the `[]` syntax:
1. Append `[]` to the parameter name (e.g., `ids` becomes `ids[]`)
2. Include each array value as a separate parameter
**Example:**
```
GET /endpoint?ids[]=1&ids[]=2&ids[]=3&names[]=Alice&names[]=Bob&limit=10
```
This submits:
- `ids` as `[1, 2, 3]`
- `names` as `["Alice", "Bob"]`
- `limit` as `10`
**In code (Python):**
```python
params = {
'ids[]': [1, 2, 3],
'names[]': ['Alice', 'Bob'],
'limit': 10
}
response = requests.get(url, headers=headers, params=params)
```
## How do I find my data center region?
Check the URL when you log into Holistics:
| URL | Region | API Base URL |
|-----|--------|--------------|
| `secure.holistics.io` | Asia-Pacific (APAC) | `https://secure.holistics.io/api/v2` |
| `eu.holistics.io` | Europe (EU) | `https://eu.holistics.io/api/v2` |
| `us.holistics.io` | United States (US) | `https://us.holistics.io/api/v2` |
See [Data Centers](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on) for more details.
## How do I get my API key?
1. Ask your admin to enable API access for your account in User Management
2. Go to [User Settings](https://secure.holistics.io/users/settings)
3. Generate a new API key
See [Authentication](/api/#authentication) for detailed steps.
## What's the difference between API v1 and v2?
API v2 is the current version with full support. API v1 is legacy and no longer actively maintained.
If you're using API v1, see [Migrating V1 to V2](/api/v2/migrating-v1-to-v2) for upgrade guidance.
## How do I handle async operations?
Some API operations (like exports) run asynchronously. These endpoints return a job ID that you poll for status:
1. Submit the request → Get `job_id`
2. Poll `GET /jobs/{job_id}` until status is `success` or `failure`
3. Retrieve the result
See [Export Widget Data](/api/v2/get-data) for a complete example.
---
## Export Widget Data via API
## Introduction
This short tutorial shows you how to use the Holistics APIs to get report data in raw tabular form (CSV, Excel).
This tutorial uses Ruby, but you can easily use any other language for it. You can also refer to the [full source code here](https://github.com/holistics/api/blob/main/ruby/report_export.rb).
:::info
We'll be working on client libraries wrapper around our API. Once done, using the APIs will be simpler by just making a few function calls.
:::
## Mechanism
Since Holistics uses a async job queuing system to process job, you can't make a single API call to retrieve the results. We need to submit an 'export request', then wait for the export job to finish, and then make an API call to download the results.
**API Endpoints used:**
- [Export a Dashboard Widget](/api/v2/reference/dashboard-widgets-submit-export)
- [Download an exported file](/api/v2/reference/exports-download)
## Steps
Let's go through the steps here.
### 1. Setting Up API Key
Please see [guide](/api/) to set up and retrieve your API key.
### 2. Initial code structure
To make things simple and reusable, we'll wrap our code around a `HolisticsAPI` class. We'll also use the [httprb](https://github.com/httprb/http) gem to handle making HTTP calls.
```ruby
require 'http'
class HolisticsAPI
def initialize(api_key, host: 'secure.holistics.io')
@api_key = api_key
@api_url = "https://#{host}/api/v2"
@http = HTTP.headers({'X-Holistics-Key' => @api_key})
end
end
```
### 3. (Optional) Get Filters ID for your Dashboard Export
If you want to include **Filters** in your export, you will need to get their Filter IDs. Please follow these steps to obtain them:
**1. Get your Dashboard ID**
The Dashboard ID can be retrieved by looking at its URL in the browser. In this sample URL below, the Dashboard ID would be 16076.

**2. Get Filter ID**
Supposed that your dashboard has a sets of filters like the one below. Let's get the ID of the **Date filter** to include it in our export.

We will use [Get Dashboard API](/api/v2/reference/dashboards-get) for this purpose. Let's call the API with the Dashboard ID from step 1.
```ruby
curl --location --request GET 'https://secure.holistics.io/api/v2/dashboards/{your_dashboard_id}' \
--header 'X-Holistics-Key: your_API_key' \
--header 'Content-Type: application/json' \
```
The response would be quite lengthy, but you just need to find the **dynamic_filters** field to get the **Filter ID**.

You can then use this Filter ID in the next step.
### 4. Submit widget export request
Make sure you have the DashboardWidget ID in hand. The widget ID can be retrieved by opening up the widget in the dashboard, and look at the `_e` parameter in the URL. For example, 4175 is the widget ID of the below.
```
https://secure.holistics.io/dashboards/v3/12345-some-dashboard/?_e=4175
```
:::info (Optional) Include filter conditions in your export
If you wish to include a filter condition in your export, first refer to [step 3](#3-optional-get-filters-id-for-your-dashboard-export) to get your desired **Filter ID**.
Then, append **dashboard_filter_conditions** in your request body.
- **dynamic_filter_id** is the Filter ID from step 3.
- **condition:**
- **operator:** refer to [Data Schedules API](/api/v2/reference/data-schedules-create) for all available operators.
- **values:** is an array of strings or integers that go with the operator.
For example, assuming that you have completed step 3, to apply a **Date Filter** that filters data from 2 months ago to the export, simply include this snippet to your request.
```ruby
{
"dashboard_filter_conditions": [
{
"dynamic_filter_id": 2335,
"condition": {
"operator": "matches",
"values": [
"2 months ago"
]
}
}
]
}
```
:::
Then we make the call to submit widget export:
```ruby
class HolisticsAPI
# ...
# output: 'csv' or 'excel'
def submit_report(widget_id, output: 'csv')
url = @api_url + "/dashboard_widgets/" + widget_id.to_s + "/submit_export"
response = @http.post(url, json: {output: output})
res = JSON.parse(response.to_s)
if response.code == 200
res['job']['id']
else
raise StandardError.new(res['message'])
end
end
end
```
If successful, this method returns the job ID of the job created.
### 5. Waiting for job to complete
The job will the go to the queue system waiting to be processed. This method below will continuously poll the job's metadata until it is either success, or failure.
```ruby
class HolisticsAPI
# ...
def wait_for_job_status(job_id)
url = @api_url + "/jobs/" + job_id.to_s
while true do
response = @http.get(url)
res = JSON.parse(response.to_s)
raise StandardError.new(res['message']) if response.code != 200
status = res['job']['status']
puts "===> status: #{status}"
unless ['created', 'running', 'queued'].include?(status)
return status
end
# Wait a while before pinging again
sleep 2
end
end
end
```
### 6. Downloading the results
Once the job finishes, we make one final API call to
```ruby
class HolisticsAPI
# ...
def download_export(job_id)
url = @api_url + "/exports/download"
response = @http.follow.get(url, params: {job_id: job_id})
raise StandardError.new(JSON.parse(response.to_s['message'])) if response.code != 200
response.to_s
end
end
```
### 7. Putting things together
Once the above class is defined, let's put in a short code to perform all 3 steps to get the data.
```ruby
API_KEY = 'your_api_key'
WIDGET_ID = 1234 # your widget
api = HolisticsAPI.new(API_KEY)
job_id = api.submit_report(WIDGET_ID)
puts "===> job_id: #{job_id}"
job_status = api.wait_for_job_status(job_id)
puts "===> job_status: #{job_status}"
if job_status == 'success'
csv_data = api.download_export(job_id)
puts csv_data # your CSV-formatted data here!
end
```
### 7. Profit!
Save the data into CSV, convert them into array, or feed them to other applications. The potentials are limitless!
---
## Getting Started with Holistics API
This guide walks you through setting up API access and making your first API call. By the end, you'll have a working example that retrieves your user information.
## Prerequisites
- A Holistics account with admin access (to enable API access for users)
- Basic familiarity with making HTTP requests (curl, Postman, or any programming language)
## Step 1: Enable API Access
Before a user can use the API, an admin must grant them API access.
1. Go to **Settings** > **User Management**
2. Edit the user who needs API access
3. Check the **Allow API access** checkbox
4. Save changes
## Step 2: Generate Your API Key
Once API access is enabled, generate your personal API key:
1. Go to [User Settings](https://secure.holistics.io/users/settings)
2. Scroll to the **API Keys** section
3. Click **Generate New Key**
4. Copy and securely store your API key

:::warning Keep your API key secure
Your API key grants full access to your Holistics account. Never share it publicly or commit it to version control.
:::
## Step 3: Find Your API Base URL
Holistics has multiple data center regions. Use the base URL that matches your region:
| Region | API Base URL |
|--------|--------------|
| Asia-Pacific (APAC) | `https://secure.holistics.io/api/v2` |
| Europe (EU) | `https://eu.holistics.io/api/v2` |
| United States (US) | `https://us.holistics.io/api/v2` |
:::tip Finding your region
Check the URL when you log into Holistics:
- `secure.holistics.io` → APAC
- `eu.holistics.io` → EU
- `us.holistics.io` → US
:::
## Step 4: Make Your First API Call
Let's verify everything works by fetching your user information:
```bash
curl -X GET \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Holistics-Key: YOUR_API_KEY" \
https://secure.holistics.io/api/v2/users/me
```
Replace `YOUR_API_KEY` with your actual API key and adjust the base URL for your region.
A successful response looks like:
```json
{
"user": {
"id": 12345,
"email": "you@example.com",
"name": "Your Name",
...
}
}
```
## Understanding Async Jobs
Many Holistics API operations (exports, schedule executions, etc.) run asynchronously. Instead of returning results immediately, these endpoints return a **job ID** that you poll until completion.
The typical pattern is:
1. **Submit a request** → Returns a `job_id`
2. **Poll the job status** → Check `/jobs/{job_id}` until status is `success` or `failure`
3. **Retrieve the result** → Use the job ID to download results or get output
For example, exporting dashboard data works like this:
```
POST /dashboard_widgets/{id}/submit_export → { "job": { "id": 123 } }
GET /jobs/123 → { "job": { "status": "success" } }
GET /exports/download?job_id=123 → CSV/Excel data
```
See the [Export Widget Data](/api/v2/get-data) tutorial for a complete working example.
## Next Steps
Now that you're set up, explore these tutorials:
- [Export Widget Data](/api/v2/get-data) - Download dashboard data as CSV/Excel
- [Create Email Schedules](/api/v2/create-data-schedule) - Automate report delivery
- [Create Data Alerts](/api/v2/create-data-alert) - Set up automated alerts
- [Query Datasets](/api/v2/query-data) - Run queries against your datasets
Or browse the [API Reference](/api/v2/reference/jobs) for all available endpoints.
---
## Migrating API V1 to API V2
## Introduction
This documentation will guide you through the process of transitioning from API V1 to the new API V2. It will also provide information about the migration plan and outline what you can expect in our new API version. Our primary goal is to assist you in achieving a smooth and successful transition to Holistics’s latest API version.
## Why it is time to move to API V2
For the past few months, we have considered API V1 as our legacy API. While it is still functional, it is not actively maintained. Meanwhile, we have been developing API V2 as a more powerful, flexible, and well-structured version of V1.
Now that API V2 has surpassed its predecessor in both functionality and experience, we are deprecating V1 and are here to assist you in migrating your applications to V2.
## Our deprecation plan for API V1
:::info
The entire API V1 will cease to function by **November 20th, 2023**.
:::
This deprecation includes all functionalities available in these two V1 kits:
- [Users API](/api/v1/user-api)
- [Group API](/api/v1/group-api)
Be assured that we have supported all of these functionalities API in V2.
Next steps, you will need to manually update your applications that are utilizing V1 to switch them to V2.
## Equivalent functions between API V1 & API V2
API V1
API V2
Changes
[User] Get all users in a tenant with full information
GET - List users
Groups are in a different list, not included in each user
V2 applies pagination, you have to query page by page
V2 has user counter information
[User] Invite a new user to Holistics
POST - Invite Multiple Users
V2 allows inviting multiple emails with additional user settings, including API usage, group IDs, and data export permissions
[User] Resend invitation to user
POST - Resend invitation to user
We introduced inviting multiple users API in API V2
In order to make invite multiple users and resend invitation user consistent, we return the job info of created job
[User] Soft-delete a user
DEL - Delete a user
[User] Restore a deleted user
POST - Restore a deleted user
[User] Allow/ Revoke a user's API access
PUT - Update user
By setting attribute: allow_authentication_token: boolean
[User] Revoke Authentication Token from a user
PUT - Update user (Upcoming)
[User] Check whether email address is already used for a user in Holistics
GET - Check whether an email address is already used for a user in your Holistics workspace
[User] Change user role in Holistics
PUT - Update user
By setting attribute: “role”: enum remove_groups: passing empty group_ids
[Group] Get all groups in a tenant
GET - List Groups
V2 applies pagination, you have to query page by page
V2 includes all users’s id instead of num_user in V1
V2 supports include_users
[Group] Create a new group
POST - Create Group
Response’s structure change
[Group] Update information of an existing group
PUT - Update a Group
Now included user_ids in groups
[Group] Delete an existing group
DEL - Delete a Group
[Group] Add a user into a group
POST - Add User to Group
[Group] Remove a user from a group
POST - Remove User from Group
## FAQ
### How will the migration affect existing applications that are utilizing API V1?
- We expect no interference in your automation workflow after the migration since all functionalities of V1 are completely covered by V2
- However, since there will be some differences in the data schema (or response structure) between the two versions, you would need to follow our documents to set up precisely
### Should I do anything prior to this migration?
We suggest you check which applications will be affected by such an update, and inform your team that they will not be available during that time.
### What do I do if I encounter unexpected issues?
We are committed to providing support throughout the migration. If you encounter any problems, please reach out to us at support@holistics.io for assistance.
---
## Query Datasets via API
This guide shows you how to query your metrics and dimensions programmatically. You can either execute queries and get results as JSON, or retrieve the generated SQL to run elsewhere. For background on why this matters, see [Open Semantic Layer](/docs/open-semantic-layer).
## How it works
You specify which dimensions and metrics you want. Holistics executes the query against your data warehouse and returns the aggregated results.
The API handles all the complexity: joining tables, applying aggregations, respecting relationships, and enforcing permissions. Your external application just receives clean, governed data.
## Quick start
### 1. Get your API key
Go to **User Settings** in Holistics and generate an API key. You'll include this key in every API request using the `X-Holistics-Key` header.
### 2. Find your dataset ID
Your metrics and dimensions are organized in datasets. To query them, you'll need the dataset ID.
**Option A: From the URL**
Navigate to your dataset in Holistics. The ID is in the URL:
```
https://secure.holistics.io/data_sets/12345/explore
^^^^^
Dataset ID
```
**Option B: Via API**
List all datasets you have access to:
```
GET /api/v2/data_sets
```
### 3. Build and send your query
Build a query specifying the dimensions and metrics you want, then POST it to one of two endpoints:
| Endpoint | Returns |
|----------|---------|
| `/data_sets/{id}/submit_query` | Executes the query and returns data as JSON |
| `/data_sets/{id}/generate_sql` | Returns the generated SQL |
Both endpoints accept the same request body:
```json
{
"query": {
"dimensions": [{"field": "model_name.field_name"}],
"metrics": [{"field": "metric_name"}],
"filters": [
{
"field": "model_name.field_name",
"operator": "operator_name",
"values": [...]
}
]
}
}
```
- **dimensions**: Fields to group by (e.g., `countries.name`, `products.category`)
- **metrics**: Aggregated measures to calculate (e.g., `gmv`, `order_count`)
- **filters**: Apply filter to your query (e.g., `countries.continent_name is Asia`)
Example: curl
```bash
curl -X POST \
-H "X-Holistics-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": {
"dimensions": [{"field": "ecommerce_countries.name"}],
"metrics": [{"field": "gmv"}]
}
}' \
https://secure.holistics.io/api/v2/data_sets/12345/submit_query
```
Example: Python
```python
response = requests.post(
"https://secure.holistics.io/api/v2/data_sets/12345/submit_query",
headers={
"X-Holistics-Key": "YOUR_API_KEY",
"Content-Type": "application/json"
},
json={
"query": {
"dimensions": [{"field": "ecommerce_countries.name"}],
"metrics": [{"field": "gmv"}]
}
}
)
response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
data = response.json()
```
### 4. Get results
The response format depends on which endpoint you called.
#### submit_query response
Returns the query results as JSON:
```json
{
"fields": ["Country Name", "GMV (Gross Merchandise Value)"],
"values": [
["United States", "1341997"],
["India", "1181929"],
["Australia", "1241734"]
],
"meta": {"page": 1, "page_size": -1, "num_rows": 10}
}
```
#### generate_sql response
Returns the generated SQL from the API call. Use this to audit query logic, debug joins, or run the SQL directly in your data warehouse.
```json
{
"sql": "SELECT \"ecommerce_countries\".\"name\", SUM(\"order_items\".\"quantity\" * \"order_items\".\"price\") AS \"gmv\" FROM ..."
}
```
## API reference
### Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/data_sets` | GET | List available datasets |
| `/data_sets/{id}` | GET | Get available dimensions and metrics |
| `/data_sets/{id}/submit_query` | POST | Query data and return results |
| `/data_sets/{id}/generate_sql` | POST | Get generated SQL without executing |
### Query object
| Field | Type | Description |
|-------|------|-------------|
| `dimensions` | array | Fields to group by: `[{"field": "model.field_name"}]` |
| `metrics` | array | Measures to calculate: `[{"field": "metric_name"}]` |
| `filters` | array | Conditions to filter data |
| `limit` | number | Maximum rows to return |
### Query examples
#### Basic query
Query GMV by country:
```json
{
"query": {
"dimensions": [{"field": "countries.name"}],
"metrics": [{"field": "gmv"}]
}
}
```
#### Multiple dimensions
Break down by country and product category:
```json
{
"query": {
"dimensions": [
{"field": "countries.name"},
{"field": "products.category"}
],
"metrics": [
{"field": "gmv"},
{"field": "order_count"}
]
}
}
```
#### Filter by value
Only include orders from specific countries:
```json
{
"query": {
"dimensions": [{"field": "countries.name"}],
"metrics": [{"field": "gmv"}],
"filters": [
{
"field": "countries.name",
"operator": "is",
"value": ["United States", "United Kingdom"]
}
]
}
}
```
#### Filter by date range
Query data for a specific time period:
```json
{
"query": {
"dimensions": [{"field": "orders.created_date"}],
"metrics": [{"field": "gmv"}],
"filters": [
{
"field": "orders.created_date",
"operator": "between",
"value": ["2025-01-01", "2025-12-31"]
}
]
}
}
```
#### Limit results
Get top 10 results:
```json
{
"query": {
"dimensions": [{"field": "products.name"}],
"metrics": [{"field": "gmv"}],
"limit": 10
}
}
```
#### Filter operators
| Operator | Description | Example value |
|----------|-------------|---------------|
| `is` | Equals any of the values | `["USA", "UK"]` |
| `is_not` | Not equal to any of the values | `["Unknown"]` |
| `is_null` | Value is null | `[]` |
| `not_null` | Value is not null | `[]` |
| `greater_than` | Greater than | `[100]` |
| `less_than` | Less than | `[1000]` |
| `between` | Between two values (inclusive) | `["2025-01-01", "2025-12-31"]` |
| `contains` | Contains substring | `["phone"]` |
| `does_not_contain` | Does not contain substring | `["test"]` |
| `starts_with` | Starts with substring | `["John"]` |
| `ends_with` | Ends with substring | `[".com"]` |
| `is_true` | Boolean is true | `[]` |
| `is_false` | Boolean is false | `[]` |
| `last` | Within last N periods | `[7, "day"]` |
| `next` | Within next N periods | `[30, "day"]` |
| `before` | Before a date | `["2025-01-01"]` |
| `after` | After a date | `["2025-01-01"]` |
| `matches` | Matches regex pattern | `["^test.*"]` |
| `matches_user_attribute` | Matches current user's attribute | `["department"]` |
### Regional endpoints
| Region | Base URL |
|--------|----------|
| APAC | `https://secure.holistics.io/api/v2` |
| US | `https://us.holistics.io/api/v2` |
| EU | `https://eu.holistics.io/api/v2` |
:::tip Finding your region
Check the URL when you log into Holistics (`secure.holistics.io` is APAC, `us.holistics.io` is US, `eu.holistics.io` is EU).
:::
### Authentication
Include your API key in the `X-Holistics-Key` header with every request. See the [Quick start examples](#3-build-and-send-your-query) above for curl and Python implementations.
## Next steps
- **Full API reference**: Explore all endpoints in the [API documentation](/api/)
---
## Rate Limiting
To ensure service stability, API requests are rate-limited per user and endpoint.
## Rate Limit Levels
| Level | Requests per Minute | Description |
|-------|---------------------|-------------|
| Level 1 | 120 | Standard API requests |
| Level 2 | 60 | Resource-intensive requests |
| Level 3 | 20 | Heavy server-load requests |
## Response Headers
Every API response includes rate limit information in the headers:
| Header | Description |
|--------|-------------|
| `RateLimit-Limit` | Your quota for this endpoint |
| `RateLimit-Remaining` | Remaining requests until reset |
| `RateLimit-Reset` | Time when the limit resets |
| `Retry-After` | When to retry (only on 429 responses) |
## Handling Rate Limits
If you exceed the limit, the API returns status code `429 Too Many Requests`.
**Best practices:**
1. **Check headers** - Monitor `RateLimit-Remaining` to avoid hitting limits
2. **Implement backoff** - When you receive a 429, wait until `Retry-After` before retrying
3. **Batch requests** - Combine multiple operations where possible
4. **Cache responses** - Avoid repeated identical requests
**Example retry logic (Python):**
```python
def api_request_with_retry(url, headers, max_retries=3):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 60))
print(f"Rate limited. Waiting {retry_after} seconds...")
time.sleep(retry_after)
continue
return response
raise Exception("Max retries exceeded")
```
---
## Delete a Conversation
Delete a Conversation
Request
---
## Get Chat Messages of a Conversation
Get Chat Messages of a Conversation
Request
---
## Get the latest insight for a conversation
Get the latest insight for a conversation
Request
---
## Get a Conversation
Get a Conversation
Request
---
## List conversations
List conversations
Request
---
## List native skills from the skills library
List native skills from the skills library
---
## List available AI skills
List available AI skills
---
## Chat with Holistics AI
Chat with Holistics AI
Request
---
## Generate insight for a conversation
Generate insight for a conversation
Request
---
## Update a conversation
Update a conversation
Request
---
## Publish the AML Project to Production
Publish the AML Project to Production
Request
---
## Validate AML Project
Validate AML Project
Request
---
## Delete a Dashboard Widget
Delete a Dashboard Widget
Request
---
## Get a Dashboard Widget
Get a Dashboard Widget
Request
---
## Export a Dashboard Widget
See more details on how to export a widget at [Get Data](/api/v2/get-data). When the Job is finished, you can download the exported file using the [download export API](/api/v2/reference/exports-download).
Request
---
## Dashboard Widgets
Reporting Dashboard Widgets
```mdx-code-block
```
---
## Build a Dashboard URL with preset filter states
Build a Dashboard URL with preset filter states
Request
---
## Clone a canvas dashboard
Clone a canvas dashboard
Request
---
## Delete a Dashboard
Delete a Dashboard
Request
---
## Get a Dashboard
Get a Dashboard
Request
---
## List all dashboards metadata
List all dashboards metadata
Request
---
## List Dashboards
List Dashboards
Request
---
## Preload a Dashboard
Preload a Dashboard by executing its widgets (using the submitted filter conditions, or default filter conditions if not submitted) and storing the widgets' results into Holistics cache.
Read more about [Data Caching](/docs/performance/data-caching).
Request
---
## Dashboards
Reporting Dashboards
```mdx-code-block
```
---
## Create a Data Alert
Create a Data Alert
Request
---
## Delete a Data Alert
Delete a Data Alert
Request
---
## Get a Data Alert
Get a Data Alert
Request
---
## List Data Alerts
List Data Alerts
Request
---
## Execute a Test Data Alert
Execute a Test Data Alert
Request
---
## Execute a Data Alert
Execute a Data Alert
Request
---
## Update a Data Alert
Update a Data Alert
Request
---
## Data Alerts
Send automatic notification to you and your team when the data meets certain conditions
```mdx-code-block
```
---
## Validate data model persistence custom DDL
Validate data model persistence custom DDL
Request
---
## List Data Models
List Data Models
Request
---
## Create a Data Schedule
Create a Data Schedule
Request
---
## Delete a Data Schedule
Delete a Data Schedule
Request
---
## Get a Data Schedule
Get a Data Schedule
Request
---
## List Data Schedules
List Data Schedules
Request
---
## Execute a Test Data Schedule
Execute a Test Data Schedule
Request
---
## Execute a Data Schedule
Execute a Data Schedule
Request
---
## Update a Data Schedule
Update a Data Schedule
Request
---
## Data Schedules
Data delivery using automated schedules
```mdx-code-block
```
---
## Delete a Data Set
We do not support deleting data set created from AML yet, try deleting it from Development. Send your request to support@holistics.io if you have any concern.
Request
---
## Get a Data Set
Get a Data Set
Request
---
## Get a SQL of a query on a dataset
Get a SQL of a query on a dataset
Request
---
## Run query on a dataset
Run query on a dataset
Request
---
## Bust Exploration Cache (Beta)
*This feature is currently in Beta.*
Bust (invalidate) all exploration/report [cache](/docs/performance/data-caching) of the specified Data Source. After a successful busting, all explorations/reports running on the specified Data Source will return fresh data.
Please note that this does not bust or trigger the Data Source's [Model Storages](/docs/storage-settings). Hence, even after calling this API, the explorations/reports can only be as fresh as the data persisted in the Model Storages.
Request
---
## Delete a Data Source
Delete a Data Source
Request
---
## Get a Data Source
Get a Data Source
Request
---
## Update a Data Source
Update a Data Source
Request
---
## Upload Multiple dbt manifest.json Files
Upload multiple dbt manifest.json files for a data source atomically.
Files are staged synchronously (size-validated in the request); parsing
and the sync run as a background job. Poll `/api/v2/reference/jobs-get`
for completion.
Limits: at most 5 files per request, 2O0 MB per file.
Malformed JSON is reported via the job's failure status, not the HTTP
response.
Gated by the `data_source:dbt_multi_manifest_upload` feature toggle.
Request
---
## Get object's downstream dependencies
Get object's downstream dependencies
Request
---
## Dependencies
## Downstream dependencies
Get ids of objects which are dependent on the object you input. The response schema varies based on the input type and its current dependants.
```mdx-code-block
```
---
## Development
Development APIs
```mdx-code-block
```
---
## Create a Shortened embed token
Use this API to [shorten the embed token](/embedded/faqs#i-received-an-error-414-request-uri-too-large-error) if the URL exceeds the browser's size limit.
Request
---
## Embed
Embedded Analytics
```mdx-code-block
```
---
## Download an exported file
Download an exported file
Request
---
## Exports
```mdx-code-block
```
---
## Process GitHub App webhook events
Endpoint for receiving webhook events from the GitHub App integration.
Handles installation lifecycle events (install, uninstall, suspend, unsuspend),
repository access changes, and repository events (pull requests, pushes).
The request signature is validated via HMAC before processing. No authentication is required.
Request
---
## Process GitHub webhook events
Endpoint for receiving GitHub webhook events related to pull requests and push events. Used for AmlStudio PR workflow integration.
Request
---
## Process GitLab webhook events
Endpoint for receiving GitLab webhook events related to merge requests and push events. Used for AmlStudio PR workflow integration.
Request
---
## Add a User to a Group
Add a User to a Group
Request
---
## Create a Group
Create a Group
Request
---
## Delete a Group
Delete a Group
Request
---
## Get a Group
Get a Group
Request
---
## List Groups
List Groups
Request
---
## Remove a User from a Group
Remove a User from a Group
Request
---
## Update a Group
Update a Group
Request
---
## Groups
Groups management
```mdx-code-block
```
---
## Holistics API(Reference)
Using these APIs, you can programmatically work with Holistics - retrieve report data, trigger schedules, manage users, and more.
For setup instructions, see the [Getting Started guide](/api/v2/getting-started).
Security Scheme Type:
apiKey
Header parameter name:
X-Holistics-Key
OAuth 2.1 Bearer token authentication
Security Scheme Type:
http
HTTP Authorization Scheme:
bearer
Contact
Holistics Support: [support@holistics.io](mailto:support@holistics.io)
---
## List Job's queues
List Job's queues
---
## Get Job's logs
Get Job's logs
Request
---
## Get Job result
Fetch the result data of a Job.
Notes:
* To check job info and status, use [GET /jobs/\{id\}](/api/v2/reference/jobs-get).
* To download the result of an export job, use [GET /exports/download](/api/v2/reference/exports-download).
Request
---
## Get a Job
Get a Job
Request
---
## List Jobs
List Jobs
Request
---
## Jobs
Async (asynchronous) operations are operations that are executed within a background Job.
If an API endpoint triggers an async operation, it will return an AsyncResult response containing the background Job info.
You can use the bellow APIs to poll for the updated Job status.
```mdx-code-block
```
---
## Submit tool call
Submit tool call
Request
---
## List available tools
List available tools
---
## Get a Query Report
Get a Query Report
Request
---
## Create a Shareable Link
Create a Shareable Link
Request
---
## Delete a Shareable Link
Delete a Shareable Link
Request
---
## Get a Shareable Link
Get a Shareable Link
Request
---
## List Shareable Links
List Shareable Links
Request
---
## Update a Shareable Link
Update a Shareable Link
Request
---
## List all production tags
Use this API to list all tags that are available in your [production tags.aml file](/docs/find-organize/tags#create-tags).
---
## Get all AML objects that are recommended to be archived
Return a list of AML dashboards and datasets which have not been viewed for more than 30 days
---
## Add tags to an object
Add tags to an object
Request
---
## Remove tags from an object
Remove tags from an object
Request
---
## Tagging System
Tagging system for content organization.
```mdx-code-block
```
---
## Compute User Attribute Values
Compute User Attribute Values
Request
---
## List User Attribute Entries
List User Attribute Entries
Request
---
## Upsert User Attribute Entries
Upsert User Attribute Entries
Request
---
## Create a User Attribute
Create a User Attribute
Request
---
## Delete a User Attribute
Delete a User Attribute
Request
---
## List User Attributes
List [User Attributes](/docs/admin/user-attributes) including [System Attributes](/docs/admin/user-attributes#system-user-attributes)
Request
---
## Update a User Attribute
Update a User Attribute
Request
---
## Check if email address is used
Check if the specified email address is already used for a User in your Holistics workspace.
Request
---
## Delete a User (soft-delete)
Revoke a User from accessing and using Holistics. Note: The soft-deleted User will still retain ownership of their resources. You can contact support@holistics.io to request for an ownership transfer.
Request
---
## Invite Users
Invite Users
Request
---
## List Users
List Users
Request
---
## Get currently authorized User
Get currently authorized User
---
## Resend invitation to User
Resend invitation to User
Request
---
## Restore a Deleted User
Restore a Deleted User
Request
---
## Revoke User API Key
Admin can revoke all users' tokens. Users can revoke their tokens.
Request
---
## Update a User
Update a User
Request
---
## Users
Users management
```mdx-code-block
```
---
## Migrating to 4.0
:::info Note
If you're using version 3.0 and wish to migrate to 4.0, please **[submit migration request here](https://form.jotform.com/231550219234044)**.
:::
## Introduction
This document is designed to assist Holistics customers in understanding the process of migrating from version 3.0 to version 4.0. It provides guidance on the migration process, from planning to execution, and covers the changes that come with version 4.0. Our goal is to ensure a successful migration to the new version.
## What is Holistics 4.0?
Holistics 4.0 is the latest version of our analytics platform, introducing a range of new concepts and features. It is designed for analytics leaders who **prioritize control, scalability, and collaboration** in their analytics processes.
Your experience remains unchanged with Holistics 4.0. Non-technical business users continue to benefit from our self-service analytics capabilities, while data teams will now have the ability to define analytics logic using code.
## Holistics 3.0 vs 4.0
### ✅ In comparison to its previous version (Holistics 3.0), Holistics 4.0 offers:
- **Analytics-as-code**: Holistics 4.0 introduces AMQL (Analytics Modeling and Query Language), allowing users to do analytics work using Holistics's code languages.
- **Git Version Control**: The new version utilizes Git for code management, allowing users to track changes, create branches, and review code for quality and collaboration.
- **AI-Powered Analytics:** Natural language querying, AI-assisted data exploration among other things.
- **dbt Integration**: Holistics 4.0 also features an integration with dbt transformations, which improves metadata consistency and reporting accuracy.
- **Canvas Dashboard**: Holistics 4.0 introduces [Canvas Dashboards](/docs/dashboards/), enabling you to create versatile and interactive data presentations. Additionally, it supports version tracking through Git.
- And so much more.
See **[3.0 vs 4.0 Feature Comparison](/docs/product-versions/3.0-vs-4.0)** for a high-level comparison between features between 3.0 and the new 4.0.
### 🤔 Some other considerations before migrating to Holistics 4.0:
- **[Deprecation of Data Imports](https://community.holistics.io/t/support-for-importing-data-from-non-sql-sources-has-ended/643):** Holistics 4.0 will not support ETL capabilities (Data Import). Please migrate all data import jobs out of your Holistics instance and delete any import operations before proceeding with the migration. We believe that there are already existing market solutions that effectively address this challenge.
- **[Data Persistence Mechanism Change](/docs/persistence):**
- In Holistics 4.0, **each execution of a persistence job generates new names for persisted tables**, unlike in Holistics 3.0 where tables maintained consistent naming. This is because persisted tables (in version 4.0) function as a caching layer to boost query performance and are not intended for direct reuse.
- If you require consistent naming for your persisted tables, we recommend creating **[a view](/docs/persistence#types-of-persistence-config)** through our Holistics 4.0 persistence option.
- **[Development Workflow Change](/docs/quickstart):**
- In Holistics 4.0, **Dataset** development has been integrated into the *Modeling* layer and is developed alongside **Models**.
- Both Models and Datasets now offer an additional code-based view, complementing the GUI view available in version 3.0.
- Holistics 4.0 also introduces an extra publish workflow to push changes from the Modeling phase to Reporting/Production (while in 3.0 version, each changes will take effect on Reporting/Production immediately).
- **[(Optional) Disable quick dashboards](/docs/dashboards#3-im-using-both-canvas-dashboards-and-quick-dashboards-is-the-older-version-going-to-be-deprecated-and-when)**: Holistics 4.0 supports both canvas dashboards and quick dashboards, but quick dashboards are legacy and won’t receive new features/improvements. We won’t automatically disable quick dashboards on your account. However, if you want to avoid confusion from having two dashboard types, you can request that quick dashboards be disabled for your tenant.
- You can’t create new quick dashboards.
- You can view, edit, and delete existing quick dashboards.
## Can I test out Holistics 4.0 before deciding to migrate?
Yes! And we strongly encourage you to do so. There are a few ways you can test out Holistics 4.0.
1. **Request a demo call**: Our team will be delighted to provide you with a personalized demo session, addressing any questions you may have about the migration process.
2. **Use our [Public Demo Account](https://demo4.holistics.io/demo) (Holistics 4.0)** : You can access a sandbox account of a shared public instance to test out the platform as an admin (non-analyst) user. This allows you to experience the data modeling and dashboard creation process.
3. **Request a new private trial account**: You can sign up for a separate private trial account from our [trial sign-up page](https://holistics.io/request-trial).
## Is there a price difference between 3.0 and 4.0?
Yes, with the release of Holistics 4.0, a different pricing model is introduced, which means your existing pricing plan will need to be replaced.
To find out more about the pricing changes, submit a [migration request using this form](https://form.jotform.com/231550219234044) and our team will reach out with the relevant information.
## What will happen with version 3.0? Will I be forced to upgrade to 4.0 in the future?
We do not assume all existing Holistics 3.0 customers will feel compelled to upgrade to Holistics 4.0, nor is it our priority to encourage them to do so.
We currently do not plan to require all customers to upgrade to Holistics 4.0. Should this change, you will receive an official announcement via email.
Your account manager will also personally reach out to guide you through the next steps. In the event of a mandatory upgrade, we will provide a 12-month notice to ensure a smooth transition for you.
## Will you still continue to build new features for version 3.0?
Holistics 4.0 brings a new architecture that enables us to expand analytics capabilities beyond what is available in 3.0. While version 3.0 will continue to receive regular updates, it is worth noting that major feature developments will primarily be focused on Holistics 4.0. This is due to the fact that these developments leverage the new architecture, allowing us to deliver more features and functionalities.
## Migrating to 4.0
### How can I request to migrate?
To upgrade to the Holistics version 4.0, please complete this [form](https://form.jotform.com/231550219234044) below:
### Pre-migration Checklist
- **Compatibility Check**: Ensure you are using Holistics 3.0 version.
- To check which version you are on, please refer to this [documentation](/docs/product-versions/check-version).
- **Clean up unsupported features**
- Since Data Imports are no longer supported in Holistics 4.0, ensure that you have migrated all your import jobs out of Holistics before proceeding with the migration.
- **Book a migration timeslot:** Schedule a migration date and time with Holistics team.
- **Inform your team members:** Notify your team members to refrain from accessing Holistics during the migration process.
### During Migration
- **Migration Duration**: The process may take up to 4 hours during Singapore office hours (GMT+8). Once the migration timeslot is confirmed, notify your users that Holistics will be unavailable during this period.
- **Backup & Restore:** Your Holistics account will be backed up before the migration takes place. In the event of any issues, we can restore your account to its previous version.
### Post-migration Checklist
- Verify that everything works correctly
- What you should check:
- Dataset Validation: Open each datasets and ensure they load properly. Verify that you can explore the datasets and perform necessary operations.
- Dashboard Validation: Confirm your dashboards load correctly and display expected data.
## How will Holistics handle unexpected issues?
In the event of any issues, Holistics has measures in place to address them:
- If certain reports and dashboards do not load properly after the migration, manual fixes may be required. Our team will provide guidance and support to help resolve these issues.
- Holistics is committed to providing dedicated support throughout and after the migration. If you encounter any problems with your reports and dashboards, reach out to us at **[support@holistics.io](mailto:support@holistics.io)** for assistance.
## FAQs
### 1. Will my dashboards' URLs change?
No, the URLs of your dashboards will remain the same as before the migration.
### 2. How will the migration affect existing data connections and integrations with external systems?
The migration process will not have any impact on your existing data connections and integrations with external systems. They will continue to function as they did in the previous version.
### 3. Will there be any changes to the data access permissions or user roles after the migration?
No, the data access permissions and user roles will remain unchanged from the previous version. You will have the same level of control and access as before.
### 4. What about all of my existing scheduled and embedded jobs?
Your existing scheduled and embedded jobs will remain unaffected. However, Data Import model jobs will be removed.
### 5. How will this migration affect my embedded dashboards?
While we anticipate a smooth transition for most embedded dashboards, there is a possibility of encountering errors if the migration script misses some configurations from the original dashboards. In such cases, you may need to manually resolve any issues with the original dashboard.
---
## AML Overview
## Introduction
**AML (Analytical Modeling Language)** is a declarative language used to define data semantic models and other analytical objects in Holistics, like datasets and dashboards. It allows users to add more meaning to data without modifying the underlying structure, and make use of Holistics's powerful **Analytical Query Language (AQL)** to perform complex reports.
:::note AML vs AQL
**AML** is how you *model* your business — the structure, types, and reusable logic of your semantic layer. **[AQL](/as-code/aql/)** is how you *query* that model — the expression language that turns it into composable metrics and reports. This page covers AML; see the [AQL docs](/as-code/aql/) for querying.
:::
## Why AML
We strongly believe in the vision of **analytics as-code** as the future of BI and analytics. However, in our opinion, there is not an adequate existing analytics-as-code language.
While imperative languages like Python and R are powerful for complex logic, they are often overkill for defining semantic models. We advocate for a **declarative language** that is more suitable for defining semantic models and analytical objects. Unfortunately, existing declarative languages such as XML, JSON, and YAML have their limitations, including excessive **verbosity, ambiguity, and a lack of type checking**. Although LookML is a strong candidate, it is proprietary and only compatible with Looker.
AML treats your analytics logic as **code, not configuration**. Three properties make this concrete:
- **First-class, typed objects** — models, dimensions, measures, metrics, and datasets are real objects with defined types, not SQL strings embedded in YAML. You can reference, pass around, and compose them.
- **Static type checking** — a built-in type system validates your model as you write it. Errors surface in the IDE at authoring time, not at query time in a broken dashboard.
- **Reusable constructs** — [Constant](/reference/aml/constant), [Func](/reference/aml/func), [Module](/reference/aml/module), [Extend](/reference/aml/extend), and [Partial](/reference/aml/partial) let you factor out and compose logic instead of copy-pasting it.
Together these make a model **maintainable**: less code, no drift, and changes that propagate from a single definition. This is what we mean by a language that is both **programmable** and **maintainable** — designed from the ground up for reusability, extensive parameterization, and modularization, so data teams can scale their analytical logic effortlessly as needs evolve.
### Static types as a feedback loop for AI
Because AML is statically typed, the compiler checks every edit against the model immediately, whether a person writes it or an AI agent generates it. When an agent produces an invalid reference or a type mismatch, the compiler flags it at once, rather than letting the error surface later as a wrong number on a dashboard. This tight author-to-check loop is what makes agentic development on the semantic layer safe to trust.
## Design principles
* **Declarative**: AML is designed for data analysts to declaratively specify the data semantic models and logic, not how to build those imperatively.
* **Friendly yet rigorous syntax**: AML syntax is crafted with data analysts in mind, drawing inspiration from familiar languages like JSON, YAML, and JavaScript. It strikes a balance between being strict and well-defined, which promotes clarity and minimizes ambiguity in data and configuration expressions.
* **Static type checking**: AML features a robust built-in static type system that provides instant feedback, enhancing the developer experience with top-notch smart auto-completion and template suggestions directly within the IDE. Both humans and AI agents catch mistakes at authoring time.
* **Composability and Reusability**: Every component of the language is designed for reuse and composition, utilizing features such as [Constant](/reference/aml/constant), [Function](/reference/aml/func), [Module](/reference/aml/module), [Extend](/reference/aml/extend) — so analytics logic scales across a team without duplication or drift.
## Using AML in Holistics
AML are written in `..aml` files in the [Development workspace](/docs/development/aml-studio#supported-files). Currently the following objects are defined using AML:
- [Model](/reference/aml/model): `model_name.model.aml`
- [Dataset](/reference/aml/dataset): `dataset_name.dataset.aml`
- [Relationships](/reference/aml/relationship): `relationships.aml`
- [Dashboard](/reference/aml/dashboard): `dashboard_name.page.aml`
When users create a new object in the Modeling layer, base AML codes are automatically generated for the object. Users can extend on these bases either via the GUI, or manually write AML definitions (like dimension and measure definition, or model relationships...) which will also be reflected visually.
## Use cases highlights
Some common reporting use cases that can be solved easily with AML and AQL includes:
- [Cohort Retention](/as-code/aql/cookbook/aql-cohort-retention)
- [Cumulative Metrics](/as-code/aql/cookbook/aql-cumulative-metrics)
- [Nested Aggregation](/as-code/aql/cookbook/aql-nested-aggregation)
- [Period comparison](/as-code/aql/cookbook/aql-period-comparison)
- [Role-playing dimension](/docs/modeling/modeling-patterns/role-playing-dimensions)
- ...
Please visit our [Guides](/guides/) for more examples of how to leverage AML and AQL to answer your business questions.
## Quick start
To quickly get used to AML, we suggest checking out the following pages:
- [AML Examples](/reference/aml/examples)
- [AML Model](/reference/aml/model)
- [AML Dimension & Measure](/reference/aml/field)
- [AML Dataset](/reference/aml/dataset)
- [AML Dashboard](/reference/aml/dashboard)
More documents can be found in the sidebar.
---
## AML Reusability Guide
[AML](/reference/aml/) provides [several features](/as-code/aml/reusability-overview#features) to reuse code and logic across different objects, improving maintainability and efficiency in analytics development. Almost _anything_ created in AML can be defined once and reused by any other AML object.
Here are some reusability use cases that you may be interested in:
* [Create a chart once and use it in multiple dashboards](/docs/canvas-dashboard/reusable-components)
* [Create part of a dashboard and use it in multiple dashboards](/embedded/dashboard-templates)
* [Define dimensions, metrics, calculations and re-use in multiple models](/as-code/aml/use-cases/extend-create-new-models-on-the-fly)
* etc
By reusing logic, you can avoid duplication, reduce errors, and make your code more modular and scalable. This page acts as a guide to provide an overview of the general steps needed to reuse logic in AML.
## The Reusability Process
* Step 1: **Identify** the logic to be reused and the resulting object that will reuse said logic.
* Step 2: **Extract** reused logic to const or Partial for later reference.
* Step 3: **Refer** to the reused logic in the resulting object.
* Step 4 (optional): **Extend** the reused object to further customize it. This is useful when you want to mostly reuse the whole object, but with some additional small modifications.
* Step 5 (optional): **Parameterize** logic with Func: for cases when you need to customize the reused logic with different parameters.
## Step 1: Identify the logic to be reused
In order to reuse logic in AML, you should first identify **the logic to be reused**, and the **resulting object that reuse said logic**. Examples of reused logic include:
* [SQL and AQL code snippets](/as-code/aml/use-cases/const-reuse-sql-definitions), reused in metrics and dimensions' definitions
* Dataset's metrics, [reused in other datasets](/as-code/aml/use-cases/extend-metric-store)
* Model's dimensions, reused in other models
* Dashboard's blocks and themes, [reused in other dashboards](/docs/canvas-dashboard/reusable-components)
* etc.
## Step 2: Extract the reused logic to const or Partial
Top level objects in AML with explicit types (e.g. DataModel, Dataset, Dashboard, etc.) are already **AML constants** and ready to be reused, thus **no further extraction** is needed. An object's properties (Dashboard's blocks, Model's dimensions, etc.) are also ready to be **reused individually** (see [step 3](/as-code/aml/reusability-guide#step-3-refer-to-the-reused-object-or-its-properties) for details).
There are 3 cases when extracting reused logic is necessary:
* For reusing primitive values such as numbers, strings, etc.
* For reusing non-top level objects in AML such as `VizBlock`, you can extract them to a const object.
* For reusing parts of an object, you can extract them to a [Partial](/reference/aml/extend#reuse-extended-logic-with-aml-partials) object.
**Primitive values** can be extracted to const objects. For example:
```aml title="Extracting primitive values to const"
const my_const = 123
const my_string = "Hello, World!"
```
Non-top level objects are sub-objects that are part of another object, such as a `VizBlock` or `PageTheme` in a `Dashboard`. **Non-top level objects with explicit types** can be extracted to a const object.
```aml title="Extracting a dashboard block to const"
// Equivalent: const my_block = VizBlock {...}
VizBlock my_block {
// details omitted...
}
```
**Multiple properties of objects** (such as dataset's metrics, data model's fields) can be extracted together, at once, to a [Partial](/reference/aml/extend#reuse-extended-logic-with-aml-partials) object. Note that **any subset of an object**'s properties can be put in a partial.
```aml title="Extracting a group of dataset metrics to a PartialDataset"
PartialDataset my_dataset {
description: "This is a dataset that contains metrics a and b"
metric a {
// details omitted...
}
metric b {
// details omitted...
}
}
```
```aml title="Extracting a group of model dimensions to a PartialModel"
PartialModel my_model {
dimension a {
// details omitted...
}
dimension b {
// details omitted...
}
}
```
To reuse `Partial` objects, you can use `extend` function. Please refer to [step 4](/as-code/aml/reusability-guide#step-4-optional-extend-an-existing-object) for details.
:::tip Use AML Module to group extracted objects
Extracted objects can quickly clutter your project files, making it difficult to navigate your work. To improve organization, consider grouping extracted AML objects and functions into an [AML module](/reference/aml/module). An AML module is essentially a directory that serves as a container for these related items, helping you maintain a cleaner and more structured project.
:::
## Step 3: Refer to the reused object or its properties
For **primitive values**, you can refer to them directly by their name, or use [string interpolation](/reference/aml/string-interpolation) feature.
```aml title="Refer to a const value"
const my_const = 123
const color_blue = "#0000FF"
PageTheme my_theme {
background_color: color_blue
}
```
```aml title="Refer to a const value with string interpolation"
const today = @sql current_date() ;;
Model today_sales {
description: "Sales data for today"
query: @sql select * from sales where date = ${today} ;;
// other details omitted...
}
```
For full object with explicit names, you can refer to them directly just with the **name itself**.
For **individual properties of objects**, you can refer to them by using the **object's name followed by a dot and the property name**. For examples:
```aml title="Refer to another model's dimension"
Model original_model {
dimension my_dimension {
// details omitted...
}
}
Model another_model {
dimension another_dimension: original_model.dimension.my_dimension
}
```
```aml title="Refer to another dataset's metric"
Dataset original_dataset {
metric a {
// details omitted...
}
}
Dataset another_dataset {
metric b: original_dataset.metric.a
}
```
```aml title="Refer to another dashboard's block"
Dashboard original_dashboard {
block my_block {
// details omitted...
}
}
Dashboard another_dashboard {
block another_block: original_dashboard.block.my_block
}
```
## Step 4 (optional): Extend an existing object
Refering to the reused object directly is useful when you want to reuse the whole object. In order to reuse an existing object with **some additional modifications**, you can use the `extend` function. This function can be applied on an analytics object to produce a new object that takes on the original properties, while merging in the additional properties.
```aml title="Extend an existing object"
Model base_model {
dimension a {
// details omitted...
}
}
Model another_model = base_model.extend({
dimension b {
// details omitted...
}
})
```
Another use case is to reuse a `Partial` object. You can use `extend` function to combine the `Partial` object with another object.
```aml title="Extend an existing object with Partial"
PartialModel my_partial_model {
dimension a {
// details omitted...
}
}
Model base_model {
// details omitted...
}
Model another_model = base_model.extend(my_partial_model)
```
More details about `extend` function can be found in the [AML Extend](/reference/aml/extend) page.
## Step 5 (optional): Parameterize logic with Func
When you need to customize the reused logic with different parameters, you can use `Func` to parameterize the logic. `Func` is a reusable block of code designed to perform a specific task. It can take parameters and return a value.
```aml title="Parameterize logic with Func"
Func sum(x: Number, y: Number) {
x + y
}
Func double(x: Number) {
const multiple = 2
x * multiple
}
Func myvizBlockWithDataset(dataset: String) { // auto infer return type = VizBlock
VizBlock {
label: 'A pie chart'
viz: PieChart {
dataset: dataset // use the dataset parameter here instead of hard-coding
legend: r(public_users.role)
series {
field {
ref: r(public_users.id)
aggregation: 'sum'
}
}
}
}
}
```
```aml title="Using Func in an object"
Dashboard my_dashboard {
// details omitted...
block my_block: myvizBlockWithDataset('ecommerce')
}
```
More details about `Func` can be found in the [AML Func](/reference/aml/func) page.
## Conclusion
By following the steps above, you can effectively reuse logic in AML, making your analytics development process more efficient and maintainable. For more details on AML's reusability features, please refer to the [AML Reusability Overview](/as-code/aml/reusability-overview).
---
## AML Reusability Overview
## Introduction
Analytics logic can become increasingly complex and difficult to maintain as more models, datasets, and dashboards are added. Analysts may end up reinventing the wheel, duplicating efforts and wasting time building similar functionality across different parts of the system.
One of the key design goals of AML (Analytics Modeling Language) is to make the language highly reusable, addressing this common challenge. Unlike pure data serialization formats like XML, JSON, or YAML, AML provides the ability to **define reusable components directly within the language**.
These reusability mechanisms enable analysts to factor out and abstract repeated logic, making it easy to share and reuse these components across different datasets and dashboards. This helps reduce duplication, improves maintainability, and increases overall efficiency in the analytics development process.

## Features
Here are the list of reusability features that AML supports:
* [AML Constant](/reference/aml/constant): An AML constant allows you to specify a value that can be reused throughout a project.
* [AML Function](/reference/aml/func): An AML function is a reusable block of code designed to perform a specific task.
* [AML Module](/reference/aml/module): An AML module is a directory containing related AML objects and functions together.
* [AML Extend](/reference/aml/extend): AML Extend is a function that can be applied on an analytics object to produce a new object that takes on the original properties.
* [AML Partial](/reference/aml/partial): AML Partial lets you extract shared logic (such as common dimensions or measures) into a reusable object that can be composed into multiple models.
* [AML String Interpolation](/reference/aml/string-interpolation): String interpolation is a feature that allows embedding variable values directly into strings.
* [AML If-else](/reference/aml/if-else): A control-flow expression that conditionally evaluates and returns values, enabling reusable definitions that branch on configuration.
## Understanding AML Extend
AML Extend is one of the most powerful reusability features. It has three key properties:
- **Inheritance**: When you edit properties in the base objects, these changes propagate to all extending objects.
- **Additive**: You can add new properties to the extending objects that do not exist in the base objects.
- **Overriding**: You can modify properties that the extending objects inherit from the base objects, without modifying the original properties.
When you use `extend()`, AML follows these steps:
1. Clone the object that is being extended
2. Create a new extending object from the extending code
3. Merge and resolve any conflicts between these two objects. If a field is defined in both objects, AML always uses the version in the extending object (**overriding**)
Example:
```aml
Model users {
label: 'Users'
dimension id { ... }
}
Model activatedUsers = users.extend({
label: 'Activated users'
dimension activated_at { ... }
})
// Steps:
// 1. Clone the "users" object into a new copy (users_cloned)
// 2. Create a new extending object from the extending code block
// {
// label: 'Activated users'
// dimension activated_at { ... }
// }
// 3. Merge users_cloned to the newly extending object.
// The 'label' field will be overridden by the new value.
// Final result:
Model {
label: 'Activated users'
dimension id { ... }
dimension activated_at { ... }
}
```
## Use Cases
* [Implementing role-playing dimensions](/docs/modeling/modeling-patterns/role-playing-dimensions#approach-2-using-extend)
* [Implement a metric store](/as-code/aml/use-cases/extend-metric-store)
* [Expose different data for different teams](/as-code/aml/use-cases/extend-reuse-modify-expose-data)
* [Reuse models but dynamically dimensions](/as-code/aml/use-cases/extend-reuse-modify-values)
* [Compose new data from existing dimensions and measure](/as-code/aml/use-cases/extend-create-new-models-on-the-fly)
* [Customize Client Dashboards](/embedded/dashboard-templates)
## Guide
Please refer to the [AML Reusability Guide](/as-code/aml/reusability-guide) for detailed steps on how to utilize AML's reusability features.
---
## Build multiple pre-aggregates using AML Extend
## Introduction
When setting up [Aggregate Awareness](/docs/aggregate-awareness/quick-start), it is a common need to create different PreAggregates for different _time granularities_ so that you can configure more _efficient_ persistence pipelines. For example:
* A PreAggregate with time granularity `month` only needs to be persisted once a month.
* While PreAggregate with time granularity `week` needs to be persisted once a week.
To conveniently generate multiple PreAggregates for different time granularities, you can leverage AML Reusability!
VIDEO
## Without reusability
Here's how you define it without reusability.
```aml title="movie_rating_analysis.dataset.aml"
Dataset movie_rating_analysis {
...
pre_aggregates: {
aggr_movie_ratings_monthly: PreAggregate {
dimension timestamp {
for: r(public_ratings.timestamp)
time_granularity: 'month'
}
measure highest_rating {
for: r(public_ratings.rating)
aggregation_type: 'max'
}
measure lowest_rating {
for: r(public_ratings.rating)
aggregation_type: 'min'
}
measure sum_rating {
for: r(public_ratings.rating)
aggregation_type: 'sum'
}
persistence: IncrementalPersistence {
schema: 'persisted'
incremental_column: 'timestamp'
}
},
aggr_movie_ratings_weekly: PreAggregate {
dimension timestamp {
for: r(public_ratings.timestamp)
time_granularity: 'week'
}
measure highest_rating {
for: r(public_ratings.rating)
aggregation_type: 'max'
}
measure lowest_rating {
for: r(public_ratings.rating)
aggregation_type: 'min'
}
measure sum_rating {
for: r(public_ratings.rating)
aggregation_type: 'sum'
}
persistence: IncrementalPersistence {
schema: 'persisted'
incremental_column: 'timestamp'
}
},
aggr_movie_ratings_daily: PreAggregate {
dimension timestamp {
for: r(public_ratings.timestamp)
time_granularity: 'day'
}
measure highest_rating {
for: r(public_ratings.rating)
aggregation_type: 'max'
}
measure lowest_rating {
for: r(public_ratings.rating)
aggregation_type: 'min'
}
measure sum_rating {
for: r(public_ratings.rating)
aggregation_type: 'sum'
}
persistence: IncrementalPersistence {
schema: 'persisted'
incremental_column: 'timestamp'
}
}
}
}
```
As shown in this example, we have to repeat many things: `persistence`, `highest_rating`, `lowest_rating`, `sum_rating`.
* If we want to add more measures in the future, we have to add 3 times.
* If we want to create more pre-aggregates in, for example, `year`, we again have to repeat almost everything.
## Refactoring using AML Extend
Now let's refactor them for better reusability using [AML Extend](/reference/aml/extend).
We can update the above codes using 2 steps:
**Step 1**: Pick one PreAggregate (e.g. `aggr_movie_ratings_daily`) and turn it into a variable.
:::info Notes
* To declare a variable, you need to do it _outside_ of your `Dataset` declaration.
* You can also declare this variable in a separate file!
:::
```aml title="movie_rating_analysis.dataset.aml"
PreAggregate aggr_movie_ratings_daily {
dimension timestamp {
for: r(public_ratings.timestamp)
time_granularity: 'day'
}
measure highest_rating {
for: r(public_ratings.rating)
aggregation_type: 'max'
}
measure lowest_rating {
for: r(public_ratings.rating)
aggregation_type: 'min'
}
measure sum_rating {
for: r(public_ratings.rating)
aggregation_type: 'sum'
}
persistence: IncrementalPersistence {
schema: 'persisted'
incremental_column: 'timestamp'
}
}
Dataset movie_rating_analysis {
...
}
```
**Step 2**: Create other PreAggregates by _extending_ the variable we just created.
```aml title="movie_rating_analysis.dataset.aml"
Dataset movie_rating_analysis {
...
pre_aggregates: {
aggr_movie_ratings_monthly: aggr_movie_ratings_daily.extend({
dimension timestamp {
for: r(public_ratings.timestamp)
time_granularity: 'month'
}
}),
aggr_movie_ratings_weekly: aggr_movie_ratings_daily.extend({
dimension timestamp {
for: r(public_ratings.timestamp)
time_granularity: 'week'
}
}),
aggr_movie_ratings_daily: aggr_movie_ratings_daily
}
}
```
Just like that, we reduced 66 lines of code into 35 lines of code, making it **more maintainable** and **more readable** at the same time.
[AML Extend](/reference/aml/extend) has made this so convenient!
---
## Define & reuse global SQL definitions
Let's say that you define a dimension called `price_category` using SQL in one data model. As you work on other data models, you notice that the same SQL definition is also needed.
With [AML Constant](/reference/aml/constant), you can declare the SQL definition as a const within a separate AML file, and then reuse it in any relevant data models in your AML project. This will help reduce code duplication, and eliminate the need to update multiple piece of code.
```aml
// Define a SQL definition as const in a separate AML file
// highlight-next-line
// In reused_sql_defs.aml
const price_category_sql = @sql select
case when price >= 3000 then 'Expensive'
else 'Cheap' end as price_category;;
// highlight-next-line
// In order.model.aml
DataModel order {
// ...
table_name: 'ecommerce_orders'
data_source_name: 'ecommerce'
dimension price_category {
label: "Price Category"
type: "text"
hidden: false
// highlight-next-line
definition: price_category_sql
}
}
// highlight-next-line
// In order_item.model.aml
DataModel order_item {
// ...
table_name: 'ecommerce_order_items'
data_source_name: 'ecommerce'
dimension price_category {
label: "Price Category"
type: "text"
hidden: false
// highlight-next-line
definition: price_category_sql
}
}
```
---
## Create new models from existing dimensions and measures
You can use [AML Extend](/reference/aml/extend) to define groups of reusable dimensions/measures, and combine them to create a new data model on the fly chaining extensions together.
```tsx
// highlight-next-line
PartialModel revenue_metrics {
measure gmv { ... }
measure mrr { ... }
measure arr { ... }
}
// highlight-next-line
PartialModel location {
dimension city_name { ... }
dimension country_code { ... }
}
// highlight-next-line
PartialModel date {
dimension created_at { ... }
dimension updated_at { ... }
}
// Chaining multiple extends
// highlight-next-line
Model merchant_full = company
.extend(revenue_metrics)
.extend(location)
.extend(date)
// highlight-next-line
Model merchant_secure = company
.extend(location)
.extend(date)
```
---
## Implement Reusable Metric Store
## Overview
With [AML Extend](/reference/aml/extend), you can define metrics once and reuse them across multiple datasets. This approach helps you:
- Avoid duplicating logic for every new business question
- Manage important business logic in one central place
- Maintain a lean analytics codebase that's easier for new team members to learn
## Key Concepts
Before diving into examples, understand these two approaches for organizing reusable metrics:
1. **Define metrics separately** - Metrics can be declared as standalone objects, making them independently reusable
2. **Group metrics with PartialDataset** - Related metrics can be organized into thematic groups using `PartialDataset` for easier management and reuse
You can mix both approaches to build a flexible metric store that suits your team's needs.
## How to Reuse Metrics
### Approach 1: Define Metrics Separately
You can define individual metrics as standalone objects and reference them when needed:
```aml title="metrics.aml"
// Define standalone metrics
Metric total_orders {
label: "Total Orders"
type: "number"
description: "Total number of orders placed"
definition: @aql count(ecommerce_orders.id);;
}
Metric gmv {
label: "GMV (Gross Merchandise Value)"
type: "number"
description: "Total value of all orders before discount"
definition: @aql ecommerce_order_items | sum(ecommerce_order_items.quantity * ecommerce_products.price);;
format: "[\$\$]#,###0"
}
Metric revenue {
label: "Total Revenue"
type: "number"
description: "Total revenue after applying commission rate"
definition: @aql nmv * revenue_commission;;
format: "[\$\$]#,###0"
}
Metric aov {
label: "AOV (Average Order Value)"
type: "number"
description: "Average value per order"
definition: @aql gmv / total_orders;;
format: "[\$\$]#,###0"
}
```
Then extend your dataset with individual metrics:
```aml title = "company.dataset.aml"
Dataset company {
// Base dataset definition
// Omitted for brevity
}
// Add individual metrics to the dataset
// highlight-next-line
Dataset company_with_metrics = company.extend({
metric total_orders: total_orders
metric gmv: gmv
metric revenue: revenue
})
```
### Approach 2: Group Related Metrics with PartialDataset
For better organization, group related metrics into thematic `PartialDataset` objects. This makes it easier to reuse entire groups of metrics:
```aml title="metrics.aml"
// Define individual metrics first
Metric total_orders {
label: "Total Orders"
type: "number"
definition: @aql count(ecommerce_orders.id);;
}
Metric total_delivered_orders {
label: "Total Delivered Orders"
type: "number"
definition: @aql total_orders | where(ecommerce_orders.status is 'delivered');;
}
Metric total_cancelled_orders {
label: "Total Cancelled Orders"
type: "number"
definition: @aql total_orders | where(ecommerce_orders.status is 'cancelled');;
}
Metric gmv {
label: "GMV (Gross Merchandise Value)"
type: "number"
definition: @aql ecommerce_order_items | sum(ecommerce_order_items.quantity * ecommerce_products.price);;
format: "[\$\$]#,###0"
}
Metric revenue {
label: "Total Revenue"
type: "number"
definition: @aql nmv * revenue_commission;;
format: "[\$\$]#,###0"
}
Metric aov {
label: "AOV (Average Order Value)"
type: "number"
definition: @aql gmv / total_orders;;
format: "[\$\$]#,###0"
}
// Group volume-related metrics
// highlight-next-line
PartialDataset volume_metrics {
metric total_orders: total_orders
metric total_delivered_orders: total_delivered_orders
metric total_cancelled_orders: total_cancelled_orders
}
// Group revenue-related metrics
// highlight-next-line
PartialDataset revenue_metrics {
metric gmv: gmv
metric revenue: revenue
metric aov: aov
}
```
Now you can reuse these metric groups across different datasets:
```aml title="company.dataset.aml"
Dataset company {
// Omitted for brevity
}
// Extend with grouped metrics
// highlight-next-line
Dataset company_analytics = company
.extend(volume_metrics)
.extend(revenue_metrics)
```
### Approach 3: Mix and Match - Chain Extensions
You can combine both approaches, extending datasets with metric groups and individual metrics:
```aml title="store.dataset.aml"
Dataset store {
// Omitted for brevity
}
// Extend with metric groups and add custom metrics
// highlight-next-line
Dataset store_analytics = store
.extend(volume_metrics)
.extend(revenue_metrics)
.extend({
// Add or override individual metrics
metric store_specific_metric {
label: "Store-Specific Metric"
type: "number"
definition: @aql gmv * (1 - store.discount_rate)
}
})
```
## Advanced: Parameterized Metrics with Functions
When you need to reuse the same metric logic across different contexts (e.g., different regions, product lines, or data models), you can use [AML Functions](/reference/aml/func) to create parameterized metrics.
### The Problem: Duplicated Metric Logic
Imagine you price products differently for US and UK markets using separate data models. Without parameterization, you'd duplicate the metric definition:
```aml
// Without parameterization - duplicated logic
PartialDataset regional_revenue_metrics {
metric order_value_us {
label: "Order Value (US)"
type: "number"
// highlight-next-line
definition: @aql orders | sum(order_item.quantity * products_us.price)
}
metric order_value_uk {
label: "Order Value (UK)"
type: "number"
// highlight-next-line
definition: @aql orders | sum(order_item.quantity * products_uk.price)
}
}
```
Notice the formula is identical except for the product model reference. This duplication becomes harder to maintain as you add more regions.
### The Solution: Use Functions to Parameterize Metrics
Instead, create a function that returns a `PartialDataset` with parameterized metrics:
```aml
// Declare a function that accepts a product model as parameter
// highlight-next-line
Func getRevenueMetrics(product_model: Model) {
PartialDataset revenue_metrics {
metric order_value {
label: "Order Value"
type: "number"
hidden: false
// Use the product_model parameter in the definition
// highlight-next-line
definition: @aql orders | sum(order_item.quantity * ${product_model.name}.price)
}
metric gmv {
label: "Gross Merchandise Value"
type: "number"
// highlight-next-line
definition: @aql orders | sum(order_item.quantity * ${product_model.name}.price * (1 - orders.discount))
format: "[\$\$]#,###0"
}
}
}
```
### Reuse Parameterized Metrics Across Datasets
Now you can reuse this metric logic for different regions by passing the appropriate product model:
```aml title="us_data.dataset.aml"
Dataset us_market {
// Base dataset definition
// Omitted for brevity
}
// Extend with US product model
// highlight-next-line
Dataset us_market_with_revenue = us_market.extend(getRevenueMetrics(products_us))
// In uk_data.dataset.aml
Dataset uk_market {
// Base dataset definition
// Omitted for brevity
}
// Extend with UK product model
// highlight-next-line
Dataset uk_market_with_revenue = uk_market.extend(getRevenueMetrics(products_uk))
```
This approach ensures your metric logic stays consistent across regions while remaining flexible enough to adapt to different data models.
## Benefits of a Reusable Metric Store
By implementing a metric store using the patterns above, you gain:
1. **Single Source of Truth** - Business logic defined once, used everywhere
2. **Easier Maintenance** - Update a metric definition in one place, all datasets benefit
3. **Consistency** - Same calculations across all reports and dashboards
4. **Scalability** - Add new metrics or metric groups without rewriting existing datasets
5. **Flexibility** - Mix and match metric groups, add custom metrics, or use parameterization for complex scenarios
---
## Expose different data to different teams
You can use [AML Extend](/reference/aml/extend) to reuse the same model but hide certain fields for different purposes.
Let’s say that you have a data model that represents employee information that you want to let the HR department and the Finance department use for different purposes.
```tsx
Model employeeInfo {
// Details of this dimension are omitted for brevity
dimension employeeID {}
dimension first_name {}
dimension last_name {}
dimension email {}
dimension salary {}
}
```
You can put this data model into a dataset and share that to both departments.
However, the Finance department may be interested in certain dimensions, such as employee’s ID and salary, so that they can calculate relevant financial metrics. Exposing names/emails are not necessary, and can pose a security risk to your company.
You can duplicate the data model into one for HR and one for Finance, but you’ll have to pay extra efforts to ensure consistency when introducing new dimensions or changing existing dimensions.
With AML Extend, you can define the base model for employee information:
```tsx
Model employeeInfo {
// Details of this dimension are omitted for brevity
dimension employeeID {}
dimension first_name {}
dimension last_name {}
dimension email {}
dimension salary {}
}
```
Then you can extend the base model and define additional dimensions or metrics for HR team.
```tsx
// highlight-next-line
Model employeeInfoHR = employeeInfo.extend({
dimension employeeEngagementScore {}
dimension recruited_from {}
})
// Add this model into a dataset and then expose the dataset to HR team
```
You can also extend the base model and hide irrelevant fields for Finance team.
```tsx
// highlight-next-line
Model employeeInfoFinance = employeeInfo.extend({
// Note that you need to specify all properties
dimension first_name {
// other properties
hidden: true
}
dimension last_name {
// other properties
hidden: true
}
dimension email {
// other properties
hidden: true
}
dimension performanceMetric {}
})
// Add this model into a dataset and then expose the dataset to Finance team
```
---
## Reuse models but dynamically modify dimensions
You can use [AML Extend](/reference/aml/extend) to reuse the same model but modify its dimensions.
Let's say that you have a product model that contains a description dimension, and you want to truncate this to the first 50 characters. You can use SQL to re-define the dimension definition in AML Extend like this:
```tsx
Model product {
// Details of this dimension are omitted for brevity
dimension product_description {
// Other propreties are omitted for brevity
defnition: @sql {{ #SOURCE.product_description }};;
}
}
// highlight-next-line
Model productWithShortDescription = product.extend({
dimension product_description {
definition: @sql left({{#SOURCE.product_description}}, 50);;
}
})
```
---
## AML vs YAML
## Introduction
> "Aren't all YAML-based analytics modeling languages similar? Why did Holistics have to invent a new modeling language instead of just use YAML?"
Most "Analytics as Code" solutions on the market use YAML as their underlying modeling markup syntax. While this is a popular choice among BI vendors, YAML remains a general-purpose markup language that poses many limitations when it comes to analytics development.
This document explains the **limitations of YAML-based modeling language**, and how **AMQL addresses those limitations**.
## YAML is ambiguous and error-prone
YAML is designed for human readability, **and it tries to be "helpful" by automatically interpreting values.** But this "helpfulness" creates a minefield of edge cases and inconsistencies.
### Inconsistent type parsing
---
Consider this seemingly innocent example:
```yaml
versions:
- test # Return 'test' (string)
- 10.5.1 # Return 10.5 (number)
- 1e+5 # Return 10000 (scientific notation)
config:
enabled: yes # String or boolean? Depends on your parser!
release: 2023-05-01 # String or date?
```
What actually gets parsed for `versions`? `["test", 10.5, 100000]` . The version number becomes a floating-point number, and the product code is interpreted as scientific notation, completely corrupting your intended data structure (all should be strings).
Also, **the same YAML document parses differently** depending on which parser and version you use. Try parsing this identical YAML in Python vs Node.js. Python returns `True` (boolean) for the field `enabled`, whereas NodeJS returns `'yes'` (string).
### Whitespace indentation nightmares
Furthermore, **YAML uses "whitespace indentation" to define nested structures**. This makes it really easy to cause error. Imagine working on a big YAML file and accidentally make a small spacing mistake in the code:
```yaml
dimensions:
- name: id
label: "ID"
- name: email
label: "Email"
```
Can you spot where the error happens? Not very easy to do, especially with large files.
These ambiguities turn simple mistakes into major debugging headaches, especially in large configuration files.
## AMQL provides clear, analytics-first syntax
AMQL eliminates these ambiguities by design with a clean, explicit syntax:
```aml
// users.model.aml
Model users {
type: 'table'
label: 'Users'
data_source_name: 'snowflakedb'
table_name: 'public.users'
dimension id {
label: 'ID'
type: 'number'
definition: @sql {{#SOURCE.id}};;
}
dimension email {
label: 'Email'
type: 'text'
definition: @sql {{#SOURCE.email}};;
}
measure user_count {
type: 'number'
label: 'Count Users'
definition: @sql count({{#SOURCE.id}});;
}
}
```
Notice the difference: **analytics objects are first-class citizens** in AMQL. Models, dimensions, and measures are native language constructs with explicit types, not generic data structures left to interpretation.
Furthermore, the curly bracket `{` syntax eliminates indentation ambiguity while creating a programming-friendly language that reads naturally and maps directly to analytics concepts.
## YAML is schemaless with no type system
Perhaps the most fundamental limitation of YAML for analytics development is **its complete lack of a type system and schema validation**. This severely impacts both your analysts' productivity and your analytics projects' reliability, creating cascading problems that grow with your codebase.
### No enforcement of required fields
YAML's schemaless nature means **there's no built-in way to define what fields are required**, **what types they should be**, or what relationships exist between different parts of your model:
```yaml
# sales_dashboard.yaml
dashboard:
title: Sales Performance
charts:
- name: Revenue by Quarter
metric: quarterly_revenue
chart_type: bar
- name: Conversion Rate
metric: conversion_rate
# highlight-next-line
**# Oops! chart_type is missing, but YAML doesn't care**
```
Without schema validation, YAML **silently accepts missing required properties**. These issues only surface at runtime (typically after production deployment) and often manifest as silent corruptions where charts display convincing but entirely wrong data.
### No autocomplete and inline documentations
Furthermore, YAML's schemaless nature creates a development experience that lacks two critical productivity features: **autocomplete and inline documentation**.
Without code suggestions or inline docs, analysts must memorize property names, valid values, and structure details, or constantly refer to external documentation.
```yaml
metrics:
- name: revenue_growth
time_grain: quater # Typo for "quarter" goes undetected
# What are valid compare_methods? No way to know without external docs
compare_method: precentage_change # Another typo that YAML happily accepts
```
This lack of guidance leads to countless wasted hours on trial-and-error, debugging typos, and hunting through documentation.
## AMQL has robust type system
AML, on the other hand, is a strongly-typed language (think TypeScript but for analytics) with a powerful built-in static type system. This allows enhancing the developer experience with instant error feedback, smart auto-completion and template suggestions directly within the IDE.
- **Smart autocomplete:** Get instant suggestions for valid attribute values
- **Inline documentation** that explains the parameters without leaving your editor.
- **Intelligent refactoring:** Go to definition, find all references, and track dependencies between dashboards, reports, and metrics
- **Instant error feedback** catches mistakes as you type them
- **Compile-time validation** that catches errors before deployment
Here are a few examples:
**Autocompletion within the analytics IDE:** the IDE auto-suggests suitable values for the "type" property of a dimension.
**Inline docs** shown when hovering over code object
**Instantly raise errors** when there's error (duplicate dimension).
Raising error instantly when required attributes not declared
**Jumps to the model's definition code** by control-clicking on name.
## YAML lacks programmability and reusability
YAML's nature as a pure data serialization format means it completely lacks programmability features. This forces analytics teams into inefficient workflows and prevents the kind of code reuse that's standard in modern development.
AML, on the other hand, is a fully programmable language that gives you variables, functions, and composition to build reusable analytics components.
### No variables, no functions, no abstractions
Want to define a constant once and reuse it? Want to parameterize your logic? Want to create reusable analytics components? YAML offers no native way to do any of this:
- No variables to store and reuse values
- No functions to encapsulate and parameterize logic
- No template mechanism for reusing structure
- No inheritance or extension mechanisms
- No conditional logic or control flow
The result is analytics code that constantly violates the DRY (Don't Repeat Yourself) principle.
Without programming constructs, YAML forces you to duplicate code for similar analytics components:
```yaml
metrics:
# Define a metric for daily active users
- name: daily_active_users
type: count_distinct
sql: "SELECT COUNT(DISTINCT user_id) FROM events WHERE event_date = CURRENT_DATE"
# Want weekly active users? Copy-paste and modify
- name: weekly_active_users
type: count_distinct
sql: "SELECT COUNT(DISTINCT user_id) FROM events WHERE event_date >= CURRENT_DATE - 7"
# Monthly active users? Copy-paste again
- name: monthly_active_users
description: "Users active in the last 30 days"
type: count_distinct
sql: "SELECT COUNT(DISTINCT user_id) FROM events WHERE event_date >= CURRENT_DATE - 30"
```
This approach is not only tedious but dangerously error-prone:
- When business logic changes (like how you define an "active" user), you must find and update every copy of the logic.
- Also, you _can't build derived metrics_ like "daily active users in Europe" that reference and extend existing metric.
Instead, you're forced to recreate the entire calculation from scratch.
Here is the equivalent version in AML:
```aml {5,13,14,22,23,31,32}
metric active_users {
label: 'Active Users'
type: 'number'
definition: @aql
count_distinct(events.user_id)
;;
}
metric daily_active_users {
label: 'Daily Active Users'
type: 'number'
definition: @aql
active_users
| where(events.event_date matches @(today))
;;
}
metric weekly_active_users {
label: 'Weekly Active Users'
type: 'number'
definition: @aql
active_users
| where(events.event_date matches @(this week))
;;
}
metric monthly_active_users {
label: 'Monthly Active Users'
type: 'number'
definition: @aql
active_users
| where(events.event_date matches @(this month))
;;
}
```
When you want to change the definition of **active users** from count distinct of user id to something else, the derived metrics `daily_active_users`, `weekly_active_users`, and `monthly_active_users` automatically pick up the change. You don't have to find and update every copy of the logic.
This code is more:
- Composable
- Reusable
- Easy to read and maintain
- Free of parsing ambiguities
### YAML + Jinja become a templating trap
Backed into a corner by YAML's limitations, many platforms resort to bolting on external templating engines. DevOps tools like Ansible have grafted string-based templating systems like Jinja onto YAML. dbt, a popular data transformation tool, also added Jinja templating on top of their SQL-based transformation language.
This is how adding Jinja templating to a YAML-based modeling language looks like:
```yaml
# Data modeling with Jinja templating - a notorious pain point
metrics:
{% for period in ['daily', 'weekly', 'monthly'] %}
{% set days = 1 if period == 'daily' else 7 if period == 'weekly' else 30 %}
- name: {{ period }}_active_users
description: "Users active in the last {{ days }} days"
type: count_distinct
sql: "SELECT COUNT(DISTINCT user_id) FROM events WHERE event_date >= CURRENT_DATE - {{ days }}"
{% endfor %}
```
While this approach might seem clever at first glance, it creates a fundamentally flawed hybrid that combines the worst aspects of both technologies:
- **Multi-layer parsing nightmares**: The code must pass through multiple parsing layers (Jinja then YAML), creating cryptic errors that reference generated code rather than your source
- **Runtime-only error detection**: Unlike proper programming languages, errors with undefined variables, type mismatches, and logic bugs only surface when templates execute, leaving you blind during development.
This approach isn't programming-it's **string manipulation masquerading as logic**. Instead of coherent language constructs, you get a fragile chain of text processing that breaks in mysterious ways.
The fact that teams resort to this approach highlights how desperately they need actual programming constructs that YAML simply cannot provide.
## AMQL is fully programmable and reusable
AMQL takes a fundamentally different approach by being a **true programming language designed specifically for analytics**. Where YAML is a data format, AMQL gives you real programming constructs.
### Metric composition: build metrics from metrics
Taking the `daily_active_users` metric as a base, we can easily build more metrics like `dau_in_europe` that reference and extend the original logic.
```aml {5,6,14}
metric dau_in_europe {
label: 'Daily Active Users in Europe'
type: 'number'
definition: @aql
daily_active_users
| where(users.country = 'Europe')
;;
}
metric dau_in_europe_pct {
label: 'EU DAU % of Total'
type: 'number'
definition: @aql
(dau_in_europe / daily_active_users) * 100
;;
}
```
If you want to change the logic of `active_users`, **do it once**, and `daily_active_users`, `dau_in_europe`, `dau_in_europe_pct` all pick it up automatically.
### Extend: build on existing models, don't rewrite them
Need a model that's 90% the same as `events` but with extra fields? `extend` grafts new properties onto an existing object:
```aml {5,12}
model user_events = events.extend({
dimension user_email {
label: 'User Email'
type: 'text'
definition: @sql {{#SOURCE.users.email}};;
}
metric conversion_rate {
label: 'Conversion Rate'
type: 'number'
definition: @aql
(count_distinct(purchases.user_id) / count_distinct(user_id)) * 100
;;
}
})
```
In YAML, you'd copy the entire model and add your changes. In AMQL, you declare the delta. The compiler merges, preserves the original's integrity, and catches conflicts at build time.
### Func: parameterize logic, not strings
When you need to generate similar analytics objects with different inputs, `Func` gives you typed, parameterized functions:
```aml {6-8}
Func active_users_for(period: String, threshold: Number) {
metric active_users_last_${period} {
label: 'Active Users (last ${period}, min ${threshold})'
type: 'number'
definition: @aql
active_users
| where(events.event_date matches @(last ${period}))
| where(events.session_count >= threshold)
;;
}
}
active_users_for('7 days', 5) // active_users_last_7_days
active_users_for('30 days', 10) // active_users_last_30_days
```
Compare to Jinja: no multi-layer parsing (Func → AML, not Jinja → YAML → SQL), no string concatenation bugs, and the IDE autocompletes parameter names because the language understands the function signature.
### Constants
```aml {5-9,16-17}
const default_currency = 'USD'
const high_value_threshold = 10000
const price_category_sql = @sql
select
case
when price >= high_value_threshold then 'Expensive'
else 'Cheap'
end as price_category
;;
metric high_value_orders {
label: 'High-Value Orders'
type: 'number'
definition: @aql
orders
| where(orders.amount >= high_value_threshold and orders.currency = default_currency)
;;
}
```
When the business redefines "high value" from \$10,000 to \$25,000, you change one constant. Every dashboard, every metric, every report that references it updates automatically.
The code sample below ties it all together: models, datasets, and metrics for calculating user conversion rates across multiple time granularities, built from modular components and composed on the spot.
## Conclusion
We're not the first to recognize YAML's limitations for analytics. Even Looker, one of the pioneers of analytics-as-code, eventually abandoned their YAML-based LookML 1.0 in favor of creating their own proprietary language (LookML 2.0). As Looker's [engineering team explained](https://web.archive.org/web/20220702090448/https://community.looker.com/lookml-5/new-lookml-why-3531?postid=6957#post6957) when announcing the change (see also [Google's migration guide](https://docs.cloud.google.com/looker/docs/best-practices/how-to-convert-yaml-to-new-lookml)): _"YAML has a lot of sharp edges... Building a parser with YAML wasn't sustainable... YAML is not a standard designed nor intended for data modeling."_
However, while Looker solved YAML's ambiguity and tooling issues, **LookML 2.0 remains essentially a markup language**. It lacks true programmability features like variables, functions, and composition. AMQL goes further by being a fully programmable language that enables the code reuse and composition that analytics teams desperately need.
We designed AMQL to systematically address every limitation of YAML for analytics development. Where YAML fails due to its nature as a general-purpose data format, AMQL succeeds by being purpose-built for analytics from the ground up through three foundational principles:
- **Clear, unambiguous syntax with first-class analytics concepts** that eliminate YAML's interpretation ambiguities
- **Robust static type system** that enables intelligent IDE features like autocomplete, inline documentation, and instant error detection
- **Native programmability** to enable true code reuse and composition without dangerous workarounds
Your analysts deserve something better than YAML.
## tldr: AMQL vs YAML
| **Category** | **YAML** | **AMQL** |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Type Safety** | ❌ Schemaless, no type checking❌ Silent errors at runtime❌ Ambiguous value interpretation | ✅ Strong static type system✅ Compile-time error detection✅ Explicit, unambiguous syntax |
| **Developer Experience** | ❌ No autocomplete or inline docs❌ Manual trial-and-error workflow❌ Whitespace indentation errors | ✅ Smart autocomplete & suggestions✅ Inline documentation✅ Instant error feedback |
| **Reusability** | ❌ No variables, functions, or abstractions❌ Forces code duplication❌ Dangerous templating workarounds | ✅ Variables, functions, and composition✅ True code reuse and modularity✅ Native programmability |
| **Analytics Focus** | ❌ General-purpose data format❌ No understanding of analytics concepts | ✅ Purpose-built for analytics✅ First-class analytics objects |
| **Parsing Consistency** | ❌ Different results across parsers❌ Version-dependent behavior❌ "Helpful" but unpredictable interpretation | ✅ Consistent, predictable parsing✅ Analytics-specific language constructs |
**Bottom Line:** YAML is a data serialization format trying to be an analytics language. AMQL is a true programming language designed specifically for analytics development.
---
## Basic Concepts
On this page, we will go through a few basic concepts that you will encounter throughout this document, give them a quick definition, and link to dedicated docs when necessary.
### Expression
A “sentence” written in AQL code that, after being evaluated, will return something, like a table, a table column, or a scalar value.
For more details, please check out the [Expressions](/reference/aql/expression) page.
### Model
An abstract layer on top of your physical database tables that is defined in a `.model.aml` file in the AML modeling layer. A model contains information about the source type (table, or query), its dimensions, measures, and other meta-data like model documents, owners…
For more details, please check out the [AML Data Model](/reference/aml/model) page.
### Iterate
In AQL, “iterate” on a table means going through all rows of that table, and apply the same operation on each row.
For example, in this transformation:
```aml
orders | select(quantity_times_10: orders.quantity * 10)
```
Behind the scenes, AQL goes through each row of the `orders` table, get the values of the `quantity` field, multiply it with 10, and assign the new values to a new field called `quantity_times_10`
### Table’s origin model
In AQL, when you apply transformations on a Table or a Field (like `select` or `filter`), it outputs a new Table or a Field object that still retains information about the first model that you used.
For a more detailed explanation, please check out the [Origin Model](/as-code/amql/aql-concepts-origin) page.
### Cross-model reference
This is one of the core features of AQL, that requires more reading to grasp. But in short, when you call `select`, `filter`, `sum`, or other functions that iterate on a `Table` with an origin model, you can reference other models that have relationships to that origin.
### Context
Context encapsulates the necessary information to evaluate an expression, like the dimensions, relationships, and the filter condition that should be applied to the calculation…
For a more detailed explanation, please check out the [Context](/as-code/aql/learn/metric-context) page.
### Types
Type can be understood as the “blueprint”, or the “category” of an object in AQL. Depending on its type, an object can have certain properties.
For more detailed explanation, please check out the [Types](/reference/aql/type-index) page.
---
## Data Modeling with AQL
## What is a data model
> A data model is an abstract representation on top of a database table/SQL query that you may manipulate without directly affecting the underlying data.
>
> from _Holistics Documentation_
This definition is a bottom-up definition. From a top-down point of view, a data model represents a *Business Entity*.
The fact that it's backed by a database table or a SQL Query is only an *implementation detail*. For example, every business has its own concept of *What a User is*. The job of a data model is to expose a concrete object representing that concept for Business Users to reason and interact with.
One crucial aspect of understanding the data model is understanding what *Dimensions* and *Measures* are.
### Measure
Measures are numeric values obtained by aggregating individual values, serving as key indicators (KPIs) for specific aspects or processes of a business. Using the context of the `user` model as an example, a measure would be something like `total_users`, `active_users`, `life_time_value`, etc.
However, measures alone may lack meaning. How can we address questions about factors such as
- "What led to the growth in Total Users this month"?
- "Where did the new users come from?"
- "Was it a result of a recent promotion?"
This is where dimensions come in
### Dimension
Dimensions are attributes of the model, and they give us the context to help us understand the meaning of the measures. For example, Total Users don't mean much as an isolated number
Using the Sign Up At dimension, we can break Total Numbers down into the month level and have more context to understand what it means
This gave us the context to understand:
- What is the increase this month?
- How is the increase compared to other months?
- Which month has the highest spike in Registered Users?
This type of analysis is called “slice-and-dice", meaning we chose an arbitrary set of dimensions to **break a measure down to the Level of Detail defined by the dimensions**.
## What is a relationship
We have established that Data Model is a representation of a Business Entity, but Business Entities don't live in isolation. A User may have multiple Orders, an Order has multiple Order Items, and an Order Item is associated with a Product, etc. These relationships are crucial for analyzing data involving multiple entities
In Holistics, when models are connected by relationships, we essentially treat them as a single large model. What this means is that the models are joined dynamically at run-time, while retaining the correct relationship between data. This approach enables end-users to focus on the business logic and effortlessly explore data without worrying about how the data is combined
For example, since we have already created a relationship between `Users` and `Orders`, we can simply drag `Full Name` field from `Users` and `Order ID` from `Orders` into the exploration.
As a result, a unique `Full Name` is associated with multiple `Order ID` values, just like how the relationship is defined which follows the underlying business logic.
## What is a dataset
> In Holistics, a **Dataset** is a "container" holding several [data models](/docs/data-model) together so they can be explored together, and dictating which join path to be used in a particular analytics use case.
Fundamentally, a dataset is a compilation of models and their relationships. They form a graph representing how the model (business entities) are related through the established relationships. With a clear understanding of the role and functionality of models and relationships, we can see that dataset is just a way to organize them to solve some particular analytics use cases
## From SQL to AQL
SQL
AMQL
Dataset
SQL doesn't inherently have a concept of datasets; data is queried from tables or views.
A container holding multiple data models together for cohesive exploration. It dictates join paths for specific analytics use cases. Fundamentally, a dataset compiles models and their relationships into a graph, representing how business entities are related.
Relationship
Based on common fields from more than one table, often involving primary and foreign keys. JOIN and ON operators are used to connect 2 related tables within the query
Based on common fields from more than one table. Relationships can be established within or separately from the datasets. Relationships enable users to treat connected models as a single large model and perform dynamic joining at runtime
Measure
An aggregation that is constructued from numerical fields and is included in SELECT clause
Any expression that can return a single scalar value and can change based on the context it is used in.
Dimension
A table field which is included in SELECT and GROUP BY clause
A dimension provides context to the measure, allowing it to be viewed from different angles and levels of detail
---
## Origin Model
In AQL, when you apply transformations on a Table or a Field (like `select` or `filter`), it outputs a new Table or a Field object that still retains information about the first model that you used.
For example, in the AML layer you have defined a model called `order_items`, and then you apply some transformations to it:
```aml
Model order_items {
dimension item_id {}
dimension order_id
dimension product_id {}
dimension quantity {}
}
Model products {
dimension id {}
dimension price {}
}
```
Imagine that you use SQL to query these two models:
```sql
select
oi.quantity * p.price as item_value
from order_items oi
left join products p on oi.product_id = p.id
```
After this SQL transformation, you will have a new table with only one column (`item_value`) and there is no information about `order_items` or `products` models.
```sql
with order_items_transformed as (
select
order_items.quantity * products.price as item_value
from order_items
left join products on order_items.product_id = products.id
)
// Invalid
select
order_items.order_id
from order_items_transformed
```
With AQL, the same transformation will also produce a new table with only a `value` column:
```aml
// Output a new virtual 'order_items_transformed' table
order_items | select(item_value: order_items.quantity * products.price)
```
However, the new table still retains the information about `order_items`. In this case, `order_items` is the origin model of the new `order_items_transformed` table. After the transformation, you can still access information of `order_items`:
```aml
order_items
| select(item_value: order_items.quantity * products.price)
| select(order_items.order_id, order_items.item_id, item_value)
```
---
## Design Principles
## Declarative and query-based language design
- Similar to how Ansible and Terraform changed the way infrastructure is defined in code, we strongly believe in the superiority of declarative based language for expressing analytics semantic models
- But defining semantic models by itself is not enough, we also have the need to have a query language
## Intuitive and easy to learn syntax
- AML's declarative syntax is carefully designed to be familiar with data analysts who work with JSON and YAML data before, while avoiding ambiguity that plagues languages like YAML.
- AQL's query syntax is designed to be familiar with analysts who has knowledge of SQL, while at the same time fixing the flaws of SQL such as lack of composability and extensibility
## Correctness
- Holistics understands that trust in analytical logic is crucial for accurate decision-making. That's why AMQL includes a powerful type system that provides real-time feedback to analysts to help them avoid errors early.
- In the future, AMQL will also include a built-in testing system that allows analytics logic to be properly tested before deploying to production.
## Developer experience
- It is 2024 and data analysts should expect their analytics logic code to have excellent developer experience like their programming language counterparts with features such as:
- Smart syntax highlighting
- Auto-completion and suggestion
- Automated refactoring
- Real time feedback
- AMQL is designed from the ground up to be able to support these features smoothly and effectively
- Holistics Development workspace and Holistics Lite’s VSCode extension provides dual code-UI mode, which not only softens the learning curve but also makes the experience more intuitive
## Reusability
- Using reusable code makes it significantly easier and quicker to create new analytics logic. Additionally, defining analytics logic in reusable code simplifies maintenance and updates, ensuring that reports and dashboards more likely to remain up-to-date.
- Unlike pure data serialization formats such as XML, JSON or YAML, AMQL provides ability to define [constants](/reference/aml/constant), [functions](/reference/aml/func), [modules](/reference/aml/module) right inside the language. These mechanisms enable analysts to factor and abstract out repeated logic and reuse them across datasets and dashboards.
- Unlike SQL, AQL allows you to save query fragments for later reuse. These fragments can then be combined to create new variations of existing queries when the need arises.
- Holistics will soon provide a packaging system and a central repository that allows users to import reusable code published by others. This will enable cross-organization reusability of analytics logic.
## Performance
- Ideally, the language should get out of the way of the analysts when they develop analytics logic, thus the performance of its toolchain is of utmost importance.
- AMQL internal architecture is designed to be incremental, requiring minimal amount of computation needed to handle small changes that are inherent in typical, day-to-day development process.
- At the same time, the processing performance of full projects is benchmarked and heavily optimized to minimize deployment time.
## Extensibility
- AMQL's parser, type checker, and interpreter are going to be open sourced, enabling developers to create custom tools on top of them.
- Unlike LookML which only supports Looker’s object, developer can declare their own types, effectively create their own domain specific objects
---
## What is AMQL?
:::info
AMQL is the analytical language powering Holistics 4.0.
:::
## What is AMQL?
AMQL (Analytics Modeling and Querying Language) is the analytics-as-code language that powers Holistics' [expressive semantic layer](/docs/difference#the-differentiator-an-expressive-semantic-layer). Using AMQL, analysts define analytics logic in code (metrics, models, datasets, dashboards) and query that logic with composable, metric-centric expressions.
AMQL is two inter-connected components:
- **[AML (Analytics Modeling Language)](/reference/aml/)**: a declarative language for defining data semantic models, business metrics, datasets, dashboards, and relationships. AML is to analytics what Terraform is to infrastructure: a typed, reviewable, version-controllable description of what you want.
- **[AQL (Analytics Query Language)](/as-code/aql/)**: a query language that operates on the semantic model defined in AML. AQL elevates **metrics to first-class composable objects**, so logic like period comparisons, cohort retention, ratios across grains, and nested aggregations stays inside the metric layer instead of leaking into SQL workarounds.
Together, AML and AQL are why Holistics' semantic layer is **expressive enough for AI to reason from**. Instead of generating SQL against raw schema, [Holistics AI](/docs/ai) generates AQL against the governed semantic layer, using the same composable definitions humans already trust. See [Why Holistics AI is reliable](/docs/ai/architecture) for the full mechanism.
AMQL is designed for data engineers, data analysts, analytics engineers, or anyone who works with analytics logic and wants **better reusability, composability, governance, and development productivity** than existing tools provide.
## Metric-centric Thinking
AMQL follows “metrics-centric thinking” paradigm. AMQL allows users to query data at a higher abstraction level, closer to how business users operate. This makes AMQL more accessible to non-technical users compared to SQL.
A query in AMQL revolves around metrics and dimensions. These metrics and dimensions are defined beforehand by analytics team.
Check out [Metrics-centric Thinking](https://community.holistics.io/t/the-ideal-semantic-layer-and-metric-centric-paradigm-blog-post/1507) for a more detailed writeup.
## Why we built AMQL?
:::tip Structural arguments
- **[AML vs YAML](/as-code/amql/aml-vs-yaml)**: why we didn't use YAML configs (the route taken by dbt, Cube, MetricFlow, LookML 1.0).
- **[AQL vs SQL](/as-code/aql/aql-vs-sql)**: why SQL strings can't be a metrics layer.
:::
We strongly believe in the vision of **analytics as-code as the future of BI and analytics**. However, in our opinion, there is not an adequate existing analytics-as-code language.
Firstly, we believe there is a need for a **declarative language to define semantic model**, similar to how Ansible and Terraform revolutionized the way infrastructure is defined in code. When we look at existing declarative languages, there are languages like XML, JSON or YAML but each of them has their own limitations. The most suitable language is LookML but it is proprietary and only works with Looker.
Secondly, we also believe there is a need for a **new query language that can leverage the semantic model defined** in the first language, built to be concise, expressive while also suitable for adhoc analysis. We believe SQL is too low level as a language and solution like adding templating language (like dbt’s Jinja use) on top is just a band-aid.
Thus, in order to realize this vision, we created AMQL to be a complete analytics-as-code language.
## Installation
AMQL is currently only available in Holistics Cloud. Support for self-hosted installation will come in future versions.
---
## AQL Functions Cheatsheet
Every AQL function in one place. Use this page to scan; jump to the dedicated reference page for full signatures, parameter details, and examples.
See also: [Operators Cheatsheet](/reference/aql/operators) · [AQL Functions Overview](/reference/aql/function).
## Table Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`select`](/reference/aql/select) | `select(table, field1, [field2, ...])` | Returns a table containing only the specified fields. |
| [`group`](/reference/aql/group) | `group(table, dimension1, [dimension2, ...])` | Returns a table grouped by one or more specified dimensions. |
| [`filter`](/reference/aql/filter) | `filter(table, condition1, [condition2, ...])` | Returns a table containing only the rows that satisfy one or more specified conditions. |
| [`unique`](/reference/aql/unique) | `unique(dimension1, [dimension2, ...])` | Returns a table with all unique combinations of the specified dimensions. |
| [`top`](/reference/aql/top) | `top(n, dimension, by: metric, [logic])` | Returns the top N values of a specified dimension based on a metric. |
| [`bottom`](/reference/aql/bottom) | `bottom(n, dimension, by: metric, [logic])` | Returns the bottom N values of a specified dimension based on a metric. |
## Condition Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`where`](/reference/aql/where) | `where(metric, condition1, [condition2, ...])` | Calculates a metric with specified conditions applied. |
## Aggregate Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`count`](/reference/aql/aggregator-functions#count) | `count([table], expression)` | Counts the total number of items in a group, excluding NULL values. |
| [`count_if`](/reference/aql/aggregator-functions#count_if) | `count_if([table], condition)` | Counts the total rows from one table that satisfy the given condition. |
| [`count_distinct`](/reference/aql/aggregator-functions#count_distinct) | `count_distinct([table], expression)` | Counts the total number of distinct items in a group, excluding NULL values. |
| [`approx_count_distinct`](/reference/aql/aggregator-functions#approx_count_distinct) | `approx_count_distinct([table], expression)` | Counts the approximate number of distinct items in a group, excluding NULL values. |
| [`average`](/reference/aql/aggregator-functions#average) | `average([table], expression)` | Calculates the average of values in a group, excluding NULL values. |
| [`min`](/reference/aql/aggregator-functions#min) | `min([table], expression)` | Returns the item in the group with the smallest value, excluding NULL values. |
| [`max`](/reference/aql/aggregator-functions#max) | `max([table], expression)` | Returns the item in the group with the largest value, excluding NULL values. |
| [`sum`](/reference/aql/aggregator-functions#sum) | `sum([table], expression)` | Calculates the sum of values in the group, excluding NULL values. |
| [`median`](/reference/aql/aggregator-functions#median) | `median([table], expression)` | Computes the median of the values in the group, excluding NULL values. |
| [`stdev`](/reference/aql/aggregator-functions#stdev) | `stdev([table], expression)` | Computes the sample standard deviation of the values in the group, excluding NULL values. |
| [`stdevp`](/reference/aql/aggregator-functions#stdevp) | `stdevp([table], expression)` | Computes the population standard deviation of the values in the group, excluding NULL values. |
| [`var`](/reference/aql/aggregator-functions#var) | `var([table], expression)` | Returns the sample variance of the values in the group, excluding NULL values. |
| [`varp`](/reference/aql/aggregator-functions#varp) | `varp([table], expression)` | Returns the population variance of the values in the group, excluding NULL values. |
| [`string_agg`](/reference/aql/aggregator-functions#string_agg) | `string_agg([table], expression, [sep: _sep], [distinct: _distinct], [order: _order])` | Returns a text that is the concatenation of all values of the expression. |
| [`corr`](/reference/aql/aggregator-functions#corr) | `corr(table, field1, field2)` | Returns the Pearson correlation coefficient of two number fields in the table. |
| [`max_by`](/reference/aql/aggregator-functions#max_by) | `max_by(table, value, by)` | Returns the value of `value` from the row where `by` is maximum. |
| [`min_by`](/reference/aql/aggregator-functions#min_by) | `min_by(table, value, by)` | Returns the value of `value` from the row where `by` is minimum. |
| [`percentile_cont`](/reference/aql/aggregator-functions#percentile_cont) | `percentile_cont([table], expression, percentile)` | Returns the value at the given percentile of the sorted expression values, interpolating between adjacent values if needed. |
| [`percentile_disc`](/reference/aql/aggregator-functions#percentile_disc) | `percentile_disc([table], expression, percentile)` | Returns the value at the given percentile of the sorted expression values. |
## Logical Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`case`](/reference/aql/logical-functions#case) | `case(when: condition, then: value, [when: condition_expr, then: value, ...], [else: value])` | Returns the value associated with the first condition that evaluates to true. |
| [`and`](/reference/aql/logical-functions#and) | `and(condition, ...)` | Returns true only when all specified conditions are true. |
| [`or`](/reference/aql/logical-functions#or) | `or(condition, ...)` | Returns true when at least one of the specified conditions is true. |
| [`not`](/reference/aql/logical-functions#not) | `not(condition)` | Logical NOT takes a single truefalse expression and returns true when the expression is false. |
## Relationship Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`with_relationships`](/reference/aql/with_relationships) | `with_relationships(metric, relationship1, [relationship2, ...])` | Calculates a metric using specific relationships that are not active by default. |
## Level Of Detail Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`of_all`](/reference/aql/of_all) | `of_all(metric, [model, dimension, ...], [keep_filters: false])` | Returns a metric evaluated without certain dimensions or grains. |
| [`keep_grains`](/reference/aql/keep) | `keep_grains(metric, [model, dimension, ...], [keep_filters: false])` | Calculates a metric only against the specified dimensions or grains, ignoring all other dimensions. |
| [`dimensionalize`](/reference/aql/dimensionalize) | `dimensionalize(metric, [dimension, ...])` | Calculates a metric at a specific Level of Detail (LoD), regardless of the outer query context. |
| [`percent_of_total`](/reference/aql/percent_of_total) | `percent_of_total(metric, total_type)` | Calculates the percentage of a metric relative to a specified total type. |
## Time-based Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`running_total`](/reference/aql/running_total) | `running_total(metric, [running_dimension, ...], [keep_filters: false])` | Calculates a running total of a metric along specified dimensions from the starting point to the current period. |
| [`period_to_date`](/reference/aql/period_to_date) | `period_to_date(metric, date_part, date_dimension)` | Calculates a metric from the beginning of a specified time period (year, quarter, month, etc.) to the current date. |
| [`exact_period`](/reference/aql/exact_period) | `exact_period(metric, time_dimension, time_range)` | Calculates a metric within a custom time period. |
| [`relative_period`](/reference/aql/relative_period) | `relative_period(metric, time_dimension, offset)` | Calculates a metric in the active time range shifted by a specified interval. |
| [`trailing_period`](/reference/aql/trailing_period) | `trailing_period(metric, date_dimension, period)` | Calculates a metric over a specific number of date periods up to the current period. |
## Window Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`rank`](/reference/aql/rank) | `rank(order: order_expr, ..., [partition: partition_expr, ...])` | Returns the rank of rows within a partition of a table. |
| [`dense_rank`](/reference/aql/dense_rank) | `dense_rank(order: order_expr, ..., [partition: partition_expr, ...])` | Returns the rank of rows within a partition of a table. |
| [`percent_rank`](/reference/aql/percent_rank) | `percent_rank(order: order_expr, ..., [partition: partition_expr, ...])` | Returns the relative percentile of a row within a partition of a table. |
| [`ntile`](/reference/aql/ntile) | `ntile(ranks, order: order_expr, ..., [partition: partition_expr, ...])` | Divides the rows within a partition into a specified number of ranked groups. |
| [`next`](/reference/aql/next) | `next(expr, [offset], order: order_expr, ..., [partition: partition_expr, ...])` | Returns the value from a following row at a specified offset relative to the current row. |
| [`previous`](/reference/aql/previous) | `previous(expr, [offset], order: order_expr, ..., [partition: partition_expr, ...])` | Returns the value from a preceding row at a specified offset relative to the current row. |
| [`first_value`](/reference/aql/first_value) | `first_value(expr, order: order_expr, ..., [partition: partition_expr, ...])` | Returns the value of an expression from the first row of the window frame. |
| [`last_value`](/reference/aql/last_value) | `last_value(expr, order: order_expr, ..., [partition: partition_expr, ...])` | Returns the value of an expression from the last row of the window frame. |
| [`nth_value`](/reference/aql/nth_value) | `nth_value(expr, index, order: order_expr, ..., [partition: partition_expr, ...])` | Returns the value of an expression from the Nth row of the window frame, where N is a positive integer. |
| [`window_sum`](/reference/aql/window_sum) | `window_sum(aggregation_expression, [range], [order: order_expression, ...], [partition: partition_expression, ...])` | Returns the sum of values in rows within a specified range relative to the current row. |
| [`window_count`](/reference/aql/window_count) | `window_count(aggregation_expression, [range], [order: order_expression, ...], [partition: partition_expression, ...])` | Returns the count of values in rows within a specified range relative to the current row. |
| [`window_min`](/reference/aql/window_min) | `window_min(aggregation_expression, [range], [order: order_expression, ...], [partition: partition_expression, ...])` | Returns the min of rows in a range relative to the current row. |
| [`window_max`](/reference/aql/window_max) | `window_max(agg_expr, [range], [order: order_expr, ...], [partition: partition_expr, ...])` | Returns the maximum value in rows within a specified range relative to the current row. |
| [`window_avg`](/reference/aql/window_avg) | `window_avg(agg_expr, [range], [order: order_expr, ...], [partition: partition_expr, ...])` | Returns the average of values in rows within a specified range relative to the current row. |
| [`window_stdev`](/reference/aql/window_stdev) | `window_stdev(agg_expr, [range], [order: order_expr, ...], [partition: partition_expr, ...])` | Returns the sample standard deviation of values in rows within a specified range relative to the current row. |
| [`window_stdevp`](/reference/aql/window_stdevp) | `window_stdevp(agg_expr, [range], [order: order_expr, ...], [partition: partition_expr, ...])` | Returns the population standard deviation of values in rows within a specified range relative to the current row. |
| [`window_var`](/reference/aql/window_var) | `window_var(agg_expr, [range], [order: order_expr, ...], [partition: partition_expr, ...])` | Returns the sample variance of values in rows within a specified range relative to the current row. |
| [`window_varp`](/reference/aql/window_varp) | `window_varp(agg_expr, [range], [order: order_expr, ...], [partition: partition_expr, ...])` | Returns the population variance of values in rows within a specified range relative to the current row. |
## Time Intelligence Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`epoch`](/reference/aql/time-intelligence-functions#epoch) | `epoch([datetime])` | Returns a Unix timestamp which is the number of seconds that have elapsed since '1970-01-01 00:00:00' UTC. |
| [`day`](/reference/aql/time-intelligence-functions#day) | `day(datetime_dimension)` | Truncates a `datetime_dimension` value to the first day of the day (midnight). |
| [`week`](/reference/aql/time-intelligence-functions#week) | `week(datetime_dimension)` | Truncates a `datetime_dimension` value to the first day of the week. |
| [`month`](/reference/aql/time-intelligence-functions#month) | `month(datetime_dimension)` | Truncates a `datetime_dimension` value to the first day of the month. |
| [`quarter`](/reference/aql/time-intelligence-functions#quarter) | `quarter(datetime_dimension)` | Truncates a `datetime_dimension` value to the first day of the quarter. |
| [`year`](/reference/aql/time-intelligence-functions#year) | `year(datetime_dimension)` | Truncates a `datetime_dimension` value to the first day of the year. |
| [`hour`](/reference/aql/time-intelligence-functions#hour) | `hour(datetime_dimension)` | Truncates a `datetime_dimension` value to the first minute of the hour. |
| [`minute`](/reference/aql/time-intelligence-functions#minute) | `minute(datetime_dimension)` | Truncates a `datetime_dimension` value to the first second of the minute. |
| [`date_trunc`](/reference/aql/time-intelligence-functions#date_trunc) | `date_trunc(datetime_dimension, datetime_part)` | Truncates a `datetime_dimension` value to the granularity of `datetime_part`. |
| [`date_part`](/reference/aql/time-intelligence-functions#date_part) | `date_part(datetime_part, datetime)` | Extracts a specific numeric part from a date or datetime value. |
| [`year_num`](/reference/aql/time-intelligence-functions#year_num) | `year_num(datetime)` | Extracts the numeric year from a datetime value. |
| [`quarter_num`](/reference/aql/time-intelligence-functions#quarter_num) | `quarter_num(datetime)` | Extracts the quarter number from a datetime value. |
| [`month_num`](/reference/aql/time-intelligence-functions#month_num) | `month_num(datetime)` | Extracts the month number from a datetime value. |
| [`week_num`](/reference/aql/time-intelligence-functions#week_num) | `week_num(datetime)` | Extracts the week number from a datetime value. |
| [`dow_num`](/reference/aql/time-intelligence-functions#dow_num) | `dow_num(datetime)` | Alias for dayofweek_num, extracts the day of week number from a datetime value. |
| [`day_num`](/reference/aql/time-intelligence-functions#day_num) | `day_num(datetime)` | Extracts the day of month number from a datetime value. |
| [`hour_num`](/reference/aql/time-intelligence-functions#hour_num) | `hour_num(datetime)` | Extracts the hour number from a datetime value. |
| [`minute_num`](/reference/aql/time-intelligence-functions#minute_num) | `minute_num(datetime)` | Extracts the minute number from a datetime value. |
| [`second_num`](/reference/aql/time-intelligence-functions#second_num) | `second_num(datetime)` | Extracts the second number from a datetime value. |
| [`date_diff`](/reference/aql/time-intelligence-functions#date_diff) | `date_diff(datetime_part, start, end)` | Calculates the difference between two dates in the specified `datetime_part`. |
| [`date_format`](/reference/aql/time-intelligence-functions#date_format) | `date_format(datetime, format)` | Formats a date according to the specified format string. |
| [`from_unixtime`](/reference/aql/time-intelligence-functions#from_unixtime) | `from_unixtime(number)` | Converts a Unix timestamp (seconds since epoch) to a datetime value. |
| [`last_day`](/reference/aql/time-intelligence-functions#last_day) | `last_day(datetime, date_part)` | Returns the last day of the period for a given date. |
| [`age`](/reference/aql/time-intelligence-functions#age) | `age(datetime)` | Returns the age in years. |
## Null/Zero Handling Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`coalesce`](/reference/aql/null-and-zero-functions#coalesce) | `coalesce(val1, val2, ...)` | Returns the first non-null value in a list of expressions. |
| [`nullif`](/reference/aql/null-and-zero-functions#nullif) | `nullif(val1, val2)` | Returns NULL if two expressions are equal, otherwise returns the first expression. |
| [`safe_divide`](/reference/aql/null-and-zero-functions#safe_divide) | `safe_divide(dividend, divisor)` | Returns the division with a safe mechanism to handle division by zero. |
## Mathematical Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`abs`](/reference/aql/math-functions#abs) | `abs(number)` | Returns the absolute value of a number, removing any negative sign and returning the non-negative magnitude. |
| [`sqrt`](/reference/aql/math-functions#sqrt) | `sqrt(number)` | Calculates the square root of a given number. |
| [`ceil`](/reference/aql/math-functions#ceil) | `ceil(number)` | Returns the smallest integer greater than or equal to the given number. |
| [`floor`](/reference/aql/math-functions#floor) | `floor(number)` | Returns the largest integer less than or equal to the given number. |
| [`round`](/reference/aql/math-functions#round) | `round(number, [scale])` | Rounds a number to a specified number of decimal places. |
| [`trunc`](/reference/aql/math-functions#trunc) | `trunc(number, [scale])` | Truncates a number to a specified number of decimal places, removing digits beyond the specified scale. |
| [`exp`](/reference/aql/math-functions#exp) | `exp(number)` | Returns the value of the mathematical constant e (Euler's number) raised to the power of the given number. |
| [`ln`](/reference/aql/math-functions#ln) | `ln(number)` | Calculates the natural logarithm (base e) of a given number. |
| [`log10`](/reference/aql/math-functions#log10) | `log10(number)` | Calculates the base 10 logarithm of a given number. |
| [`log2`](/reference/aql/math-functions#log2) | `log2(number)` | Calculates the base 2 logarithm of a given number. |
| [`pow`](/reference/aql/math-functions#pow) | `pow(base, exponent)` | Raises a base number to the power of an exponent. |
| [`mod`](/reference/aql/math-functions#mod) | `mod(dividend, divisor)` | Returns the remainder of a division operation. |
| [`div`](/reference/aql/math-functions#div) | `div(dividend, divisor)` | Returns the integer quotient of a division operation. |
| [`sign`](/reference/aql/math-functions#sign) | `sign(number)` | Returns the sign of a number: 1 for positive, -1 for negative, and 0 for zero. |
| [`radians`](/reference/aql/math-functions#radians) | `radians(degrees)` | Converts degrees to radians. |
| [`pi`](/reference/aql/math-functions#pi) | `pi()` | Returns the mathematical constant π (pi). |
| [`acos`](/reference/aql/math-functions#acos) | `acos(number)` | Returns the arccosine (inverse cosine) of a number. |
| [`asin`](/reference/aql/math-functions#asin) | `asin(number)` | Returns the arcsine (inverse sine) of a number. |
| [`atan`](/reference/aql/math-functions#atan) | `atan(number)` | Returns the arctangent (inverse tangent) of a number. |
| [`atan2`](/reference/aql/math-functions#atan2) | `atan2(y, x)` | Returns the two-argument arctangent, which computes the angle between the positive x-axis and the point given by the coordinates (x, y). |
| [`cos`](/reference/aql/math-functions#cos) | `cos(number)` | Returns the cosine of a number. |
| [`sin`](/reference/aql/math-functions#sin) | `sin(number)` | Returns the sine of a number. |
| [`tan`](/reference/aql/math-functions#tan) | `tan(number)` | Returns the tangent of a number. |
| [`cot`](/reference/aql/math-functions#cot) | `cot(number)` | Returns the cotangent of a number. |
## Miscellaneous Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`cast`](/reference/aql/miscellaneous-functions#cast) | `cast(expr, type)` | Returns the input value casted to the specified data type. |
| [`concat`](/reference/aql/text-functions#concat) | `concat(text, [text ...])` | Returns the concatenated string of multiple strings. |
| [`find`](/reference/aql/text-functions#find) | `find(text, substring)` | Returns the 1-based index of the first occurrence of a substring within a text string. |
| [`left`](/reference/aql/text-functions#left) | `left(text, length)` | Returns the leftmost characters of a text string, up to the specified length. |
| [`right`](/reference/aql/text-functions#right) | `right(text, length)` | Returns the rightmost characters of a text string, up to the specified length. |
| [`mid`](/reference/aql/text-functions#mid) | `mid(text, start, length)` | Extracts a substring of a specified length from a text string, starting at a given position (1-based). |
| [`len`](/reference/aql/text-functions#len) | `len(text)` | Returns the length of a text string (number of characters). |
| [`lower`](/reference/aql/text-functions#lower) | `lower(text)` | Converts a text string to lowercase. |
| [`upper`](/reference/aql/text-functions#upper) | `upper(text)` | Converts a text string to uppercase. |
| [`trim`](/reference/aql/text-functions#trim) | `trim(text)` | Removes leading and trailing whitespace from a text string. |
| [`ltrim`](/reference/aql/text-functions#ltrim) | `ltrim(text)` | Removes leading whitespace from a text string. |
| [`rtrim`](/reference/aql/text-functions#rtrim) | `rtrim(text)` | Removes trailing whitespace from a text string. |
| [`lpad`](/reference/aql/text-functions#lpad) | `lpad(text, length, pad_string)` | Pads the left side of a text string with a specified pad string until it reaches the specified length. |
| [`rpad`](/reference/aql/text-functions#rpad) | `rpad(text, length, pad_string)` | Pads the right side of a text string with a specified pad string until it reaches the specified length. |
| [`replace`](/reference/aql/text-functions#replace) | `replace(text, old_substring, new_substring)` | Replaces all occurrences of a substring within a text string with a new substring. |
| [`split_part`](/reference/aql/text-functions#split_part) | `split_part(text, delimiter, part_number)` | Splits a text string into parts based on a delimiter and returns the specified part (1-based). |
| [`regexp_extract`](/reference/aql/text-functions#regexp_extract) | `regexp_extract(text, regex, [occurrence], [group: _group], [flags: _flags])` | Extracts a substring from a text string that matches a regular expression pattern. |
| [`regexp_like`](/reference/aql/text-functions#regexp_like) | `regexp_like(text, regex, [flags: _flags])` | Checks if a text string matches a regular expression pattern. |
| [`regexp_replace`](/reference/aql/text-functions#regexp_replace) | `regexp_replace(text, regex, substitute, [flags: _flags])` | Replaces substrings in a text that match a regular expression pattern with a specified replacement text. |
| [`is_at_level`](/reference/aql/miscellaneous-functions#is_at_level) | `is_at_level(dimension)` | Returns true if the specified dimension is active in the Level of Detail (LoD) context, else false. |
## AI Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`ai_complete`](/reference/aql/ai-functions#ai_complete) | `ai_complete(model, prompt)` | Queries an AI model with a text prompt and returns the generated response. |
| [`ai_similarity`](/reference/aql/ai-functions#ai_similarity) | `ai_similarity(text1, text2)` | Calculates the semantic similarity between two text strings. |
| [`ai_classify`](/reference/aql/ai-functions#ai_classify) | `ai_classify(text, category1, category2, ...categories)` | Classifies text into one of the provided categories using AI. |
| [`ai_summarize`](/reference/aql/ai-functions#ai_summarize) | `ai_summarize(content)` | Generates a concise summary of the provided text content using AI. |
## SQL Passthrough Function
| Function | Syntax | Purpose |
| --- | --- | --- |
| [`sql_text`](/reference/aql/sql-passthrough-functions#sql_text) | `sql_text('FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL function that returns a text/string value. |
| [`sql_number`](/reference/aql/sql-passthrough-functions#sql_number) | `sql_number('FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL function that returns a numeric value. |
| [`sql_datetime`](/reference/aql/sql-passthrough-functions#sql_datetime) | `sql_datetime('FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL function that returns a datetime value. |
| [`sql_date`](/reference/aql/sql-passthrough-functions#sql_date) | `sql_date('FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL function that returns a date value. |
| [`sql_truefalse`](/reference/aql/sql-passthrough-functions#sql_truefalse) | `sql_truefalse('FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL function that returns a boolean/truefalse value. |
| [`agg_text`](/reference/aql/sql-passthrough-functions#agg_text) | `agg_text(table, 'FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL aggregate function that returns a text/string value. |
| [`agg_number`](/reference/aql/sql-passthrough-functions#agg_number) | `agg_number(table, 'FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL aggregate function that returns a numeric value. |
| [`agg_datetime`](/reference/aql/sql-passthrough-functions#agg_datetime) | `agg_datetime(table, 'FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL aggregate function that returns a datetime value. |
| [`agg_date`](/reference/aql/sql-passthrough-functions#agg_date) | `agg_date(table, 'FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL aggregate function that returns a date value. |
| [`agg_truefalse`](/reference/aql/sql-passthrough-functions#agg_truefalse) | `agg_truefalse(table, 'FUNCTION_NAME', param1, param2, ...)` | Calls a native SQL aggregate function that returns a boolean/truefalse value. |
---
## AQL Cheatsheet - Operators
## Text Operators
### Description
A text value, represented as a string of characters. The "text" type can include any characters, including letters, numbers, symbols, and whitespace.
### Tags
Operator, Condition
### Operators
#### `==` ; `is`
Equal to
```tsx title="Check if product name is Dandelion"
products.name == 'Dandelion'
products.name is 'Dandelion'
```
#### `!=` ; `is not`
Not equal to
```tsx title="Check if product name is not Rock"
products.name != 'Rock'
products.name is not 'Rock'
```
#### `like`
Match the pattern specified
```tsx title="Check if product name ends with Dan"
products.name like '%Dan'
```
#### `not like`
Not match the pattern specified
```tsx title="Check if product name does not end with Dan"
products.name not like '%Dan'
```
#### `ilike`
Match the pattern specified, case insensitive
```tsx title="Check if product name ends with dan, case insensitive"
products.name ilike '%dan'
```
#### `not ilike`
Match the pattern specified, case insensitive
```tsx title="Check if product name does not end with dan, case insensitive"
products.name not ilike '%dan'
```
#### `is null`
Include if the value is null
```tsx title="Check if product name is null"
products.name is null
```
#### `is not null`
Include if the value is not null
```tsx title="Check if product name is not null"
products.name is not null
```
### Learn more
[Text Operators (AQL Doc)](/reference/aql/operator#text)
## List Operators
### Description
Check if the field value is in the list.
### Tags
Operator, Condition
### Operators
#### `in`
Include if the value is in the list
```tsx title="Check if product name is in the list"
products.name in ['Dandelion', 'Rock']
```
#### `not in`
Include if the value is not in the list
```tsx title="Check if product name is not in the list"
products.name not in ['Dandelion', 'Rock']
```
### Learn more
[List Operators (AQL Doc)](/reference/aql/operator#list)
## Truefalse Operators
### Description
A boolean value, represented as either "true" or "false". This is commonly used to represent logical values. For example, it can be used to represent the result of a comparison, the status of a switch, or the answer to a yes/no question.
### Tags
Operator, Condition
### Operators
#### `is`
Equal to
```tsx title="Check if an order is paid"
orders.is_paid is true
```
#### `is not`
Not equal to
```tsx title="Check if an order is not paid"
orders.is_paid is not true
```
#### `is null`
Include if null
```tsx title="Check if the order payment status is unknown"
orders.is_paid is null
```
#### `is not null`
Include if not null
```tsx title="Check if the order payment status is known"
orders.is_paid is not null
```
### Learn more
[Truefalse Operators (AQL Doc)](/reference/aql/operator#truefalse)
## Number Operators
### Description
A numeric value, represented as an numeric, integer, float, or double. It can be used as dimension or measure type. The number type can include positive and negative values, as well as decimal points.
### Tags
Operator, Condition
### Operators
#### `==` ; `is`
Equal to
```tsx title="Check if an order item discount is 50%"
order_items.discount == 0.5
order_items.discount is 0.5
```
#### `!=` ; `is not`
Not equal to
```tsx title="Check if an order item discount is not 100%"
order_items.discount != 1
order_items.discount is not 1
```
#### `>`
Greater than
```tsx title="Check if an order item discount is greater than 50%"
order_items.discount > 0.5
```
#### `>=`
Greater than or equal to
```tsx title="Check if an order item discount is at least 50%"
order_items.discount >= 0.5
```
#### `<`
Less than
```tsx title="Check if an order item discount is less than 50%"
order_items.discount < 0.5
```
#### `<=`
Less than or equal to
```tsx title="Check if an order item discount is at most 50%"
order_items.discount <= 0.5
```
#### `is null`
Include if null
```tsx title="Check if an order item discount is unknown"
order_items.discount is null
```
#### `is not null`
Include if not null
```tsx title="Check if an order item discount is known"
order_items.discount is not null
```
#### `+`
Add two numeric values
```tsx title="Add product cost and profit"
products.cost + products.profit
```
#### `-`
Subtract two numeric values
```tsx title="Subtract product cost from revenue"
products.revenue - products.cost
```
#### `*`
Multiply two numeric values
```tsx title="Multiply order quantity and product price"
orders.quantity * products.price
```
#### `/`
Divide two numeric values
```tsx title="Divide product profit by product cost"
products.profit / products.cost
```
### Learn more
[Number Operators (AQL Doc)](/reference/aql/operator#number)
## Datetime Operators
### Description
Right hand side of datetime operator takes [a datetime scalar type](/reference/aml/date-format) as input and always starts with @ token. Datetimes can be expressed in a fully supported format as @YYYY-MM-DD HH:MM:SS, in shorter variations like @YYYY-MM, or a relative datetime (relative to the current real world time) like @(last 7 days).
### Tags
Operator, Condition
### Operators
#### `==`
Include data that equal to an absolute timestamp
```tsx title="Check if an order is created at 2022-01-01 00:00:00"
orders.created_at == @2022
```
```tsx title="Check if an order is created at the first timestamp of the last 7 days"
orders.created_at == @(last 7 days)
```
#### `is` ; `match` ; `matches`
Include data that are in a time period
```tsx title="Check if an order is created in the period of the year 2022"
orders.created_at is @2022
```
```tsx title="Check if an order is created in the period of the last 7 days"
orders.created_at match @(last 7 days)
```
#### `!=`
Include data that do not equal to an absolute timestamp
```tsx title="Check if an order is not created at 2022-01-01 00:00:00"
orders.created_at != @2022-01
```
#### `is not`
Include data that are not in a time period
```tsx title="Check if an order is not created in the period of 2022-01"
orders.created_at is not @2022-01
```
#### `<`
Include data that are before a specific time period
```tsx title="Check if an order is created before the period of the year 2022"
orders.created_at < @2022
```
#### `<=`
Include data that are before or in a specific time period
```tsx title="Check if an order is created before or in the period of the year 2022"
orders.created_at <= @2022
```
#### `>`
Include data that are after a specific time period
```tsx title="Check if an order is created after yesterday"
orders.created_at > @(yesterday)
```
#### `>=`
Include data that are after or in a specific time period
```tsx title="Check if an order is created after or on yesterday"
orders.created_at >= @(yesterday)
```
#### `is null`
Include if the value is null
```tsx title="Check if an order creation day is unknown"
orders.created_at is null
```
#### `is not null`
Include if the value is not null
```tsx title="Check if an order creation day is known"
orders.created_at is not null
```
#### `+`
Add an interval to a datetime
```tsx title="Add 3 months to the creation day"
orders.created_at + interval(3 months)
```
```tsx title="Substract 1 month to the creation day"
orders.created_at + interval(-1 month)
```
#### `-`
Subtract an interval to a datetime
```tsx title="Subtract 3 months to the creation day"
orders.created_at - interval(3 months)
```
```tsx title="Add 1 month to the creation day"
orders.created_at - interval(-1 month)
```
### Learn more
[Datetime Operators (AQL Doc)](/reference/aql/operator#datetime)
---
## AQL vs SQL
## Introduction
*"SQL is the universal language of data. Why did Holistics have to create a new query language instead of just using SQL?"*
SQL is powerful, universal, and battle-tested. It's the right tool for ad-hoc data exploration, data transformation, and building data pipelines. AQL compiles to SQL under the hood. It's designed to **complement SQL, not replace it**.
But SQL was designed in the 1970s for *querying tables*, not for *defining and composing business metrics*. When analytics teams try to build a reusable metrics layer using SQL, they run into fundamental limitations that no amount of CTEs or window functions can fix.
This document explains the **limitations of SQL for metrics-based analytics**, and how **AQL addresses those limitations**.
## SQL buries business intent in mechanical complexity
SQL requires you to solve a series of mechanical puzzles (which tables to join, what keys to use, what to GROUP BY) before you can express the actual business question. The intent gets buried under plumbing.
### Joins, GROUP BY, and ORDER BY for every query
Consider a straightforward business question: *"What is revenue by product category?"*
```sql
SELECT
c.name AS category,
SUM(oi.quantity * p.price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
GROUP BY c.name
ORDER BY revenue DESC
```
Out of 7 lines, only one (`SUM(oi.quantity * p.price)`) is actual business logic. The rest is mechanical plumbing: 2 JOINs, a GROUP BY, and an ORDER BY. And if someone later wants revenue by *merchant* instead of *category*, they need to rewrite the query with different joins and different GROUP BY, even though the business logic hasn't changed at all.
### CTEs for anything non-trivial
The moment your analysis goes beyond a single aggregation, SQL pushes you into CTEs and subqueries. Here's what it takes to answer *"How does each month's sales compare to the average?"*:
```sql
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) as month,
SUM(amount) as sales
FROM orders
GROUP BY 1
),
avg_monthly AS (
SELECT AVG(sales) as avg_sales FROM monthly_sales
)
SELECT
month,
sales,
sales - (SELECT avg_sales FROM avg_monthly) as variance
FROM monthly_sales
```
The business question is one sentence. The SQL is 14 lines across two CTEs.
## AQL expresses analytics intent directly
AQL separates the *what* from the *how*. Relationships between tables are defined once in your dataset. Queries focus purely on what you want to know.
Here's the same revenue-by-category question:
```aml
// Relationships defined once in the dataset: no need to repeat in queries
// relationship(order_items.product_id > products.id, true)
// relationship(products.category_id > categories.id, true)
metric revenue {
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
```
No joins. The same `revenue` metric works whether you slice by category, merchant, country, or time period. Just change the dimension in your exploration. The metric definition stays the same.
And the monthly variance? Three named, reusable components instead of a monolithic CTE query:
```aml
metric sales {
definition: @aql sum(orders.amount) ;;
}
metric avg_monthly_sales {
definition: @aql
orders
| group(orders.created_at | month())
| select(sales)
| avg(sales)
;;
}
metric sales_variance {
definition: @aql sales - avg_monthly_sales ;;
}
```
Each metric has a name, a clear definition, and can be reused independently. The pipe operator (`|`) chains operations so the logic reads like a sentence: *"take orders, group by month, calculate sales, then average them."*
## SQL metrics are trapped inside queries
In SQL, a metric like "revenue" is just an expression (`SUM(amount)`) embedded inside a query. It has no name you can reference later, no way to compose it with other metrics, no single source of truth. When another team member needs a variant, they copy-paste and modify.
### The copy-paste problem
```sql
-- Dashboard query 1: Total revenue
SELECT SUM(oi.quantity * p.price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id;
-- Dashboard query 2: Delivered revenue (copy-paste + WHERE)
SELECT SUM(oi.quantity * p.price) AS delivered_revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'delivered';
-- Dashboard query 3: Revenue per order (copy-paste + different aggregation)
SELECT
SUM(oi.quantity * p.price) / COUNT(DISTINCT oi.order_id) AS revenue_per_order
FROM order_items oi
JOIN products p ON oi.product_id = p.id;
```
The definition of "revenue" is now scattered across three queries. If the formula changes (say, you need to exclude refunds), you must hunt down and update every copy. Miss one, and your dashboards silently disagree with each other.
## AQL metrics are first-class, composable objects
In AQL, metrics are named objects that can be referenced, filtered, and composed (just like functions in a programming language).
```aml
// Single source of truth
metric revenue {
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
// Filtered variants: references the original, not a copy
metric delivered_revenue {
definition: @aql revenue | where(orders.status == 'delivered') ;;
}
metric cancelled_revenue {
definition: @aql revenue | where(orders.status == 'cancelled') ;;
}
// Derived metrics: composed from existing building blocks
metric revenue_per_order {
definition: @aql safe_divide(revenue, count(orders.id)) ;;
}
```
When the definition of `revenue` changes, **all derived metrics update automatically**. This isn't string substitution. AQL understands the semantic structure. You build a library of metrics that grows more powerful over time, rather than a collection of isolated queries that drift apart.
## SQL lacks built-in analytics patterns
Percent of total, year-over-year comparisons, and nested aggregations are bread-and-butter analytics operations. Yet in SQL, each one requires a verbose, error-prone pattern: window functions, self-joins, or deeply nested subqueries. These patterns are so common that they should be built-in primitives, not puzzles to solve from scratch every time.
### Percent of total
*"What percentage of total revenue does each product contribute?"*
```sql
SELECT
p.name,
SUM(oi.quantity * p.price) AS revenue,
SUM(oi.quantity * p.price) * 100.0 /
SUM(SUM(oi.quantity * p.price)) OVER () AS pct_of_total
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.name
```
`SUM(SUM(...)) OVER ()`: a nested window function over an aggregate. Many analysts struggle to write this correctly, and it's nearly impossible to read at a glance.
### Year-over-year comparison
*"What is the YoY revenue change by month?"*
```sql
WITH monthly AS (
SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS revenue
FROM orders GROUP BY 1
)
SELECT
curr.month,
curr.revenue,
prev.revenue AS prev_year_revenue,
(curr.revenue - prev.revenue) * 100.0 / prev.revenue AS yoy_change
FROM monthly curr
LEFT JOIN monthly prev
ON curr.month = prev.month + INTERVAL '1 year'
```
A self-join on a CTE with date arithmetic. Fragile and easy to get wrong. Want month-over-month too? Write it all again with a different interval.
### Nested aggregation
*"What is the average number of new customers per month?"*
```sql
SELECT AVG(monthly_count) FROM (
SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS monthly_count
FROM users GROUP BY 1
) sub
```
Even this relatively simple case requires a subquery. More complex nested aggregations quickly become deeply nested and unreadable.
## AQL has built-in analytical functions
The same three questions in AQL:
**Percent of total**: one line with `of_all()`
```aml
metric pct_of_total {
definition: @aql
safe_divide(revenue * 100.0, revenue | of_all(products))
;;
}
```
`of_all(products)` is a declarative statement: *"calculate revenue ignoring the product dimension."* No window functions, no nested aggregates.
**Year-over-year**: one function with `relative_period()`
```aml
metric revenue_last_year {
definition: @aql
revenue | relative_period(orders.created_at, interval(-1 year))
;;
}
metric yoy_change {
definition: @aql
safe_divide((revenue - revenue_last_year) * 100.0, revenue_last_year)
;;
}
```
The same metric works at any time granularity: daily, monthly, quarterly. Want month-over-month? Just change `interval(-1 year)` to `interval(-1 month)`.
**Nested aggregation**: a pipe chain that reads like a sentence
```aml
metric avg_monthly_new_customers {
definition: @aql
users
| group(users.created_at | month())
| select(monthly_count: count(users.id))
| avg(monthly_count)
;;
}
```
*"Take users, group by month, count them, then average."* Two levels of aggregation in four lines, no subqueries.
## Conclusion
SQL remains the right tool for ad-hoc data exploration and data transformation. But when it comes to building a scalable analytics layer (where metrics need to be defined once, reused everywhere, and composed into increasingly sophisticated analyses), SQL's limitations become structural barriers, not just inconveniences.
We designed AQL to systematically address every limitation of SQL for metrics-based analytics. Where SQL falls short because it was designed for *querying tables*, AQL succeeds because it was designed for *defining and composing metrics*, built on three foundational principles:
- **Intent over mechanics**: express analytics questions directly without specifying joins, GROUP BY, or query structure. Relationships are defined once; queries focus on *what* you want to know.
- **Metrics as first-class objects**: metrics are named, reusable, composable building blocks with a single source of truth, not disposable expressions scattered across queries.
- **Built-in analytics primitives**: common patterns like percent of total, time comparisons, and nested aggregations are single functions, not verbose SQL workarounds.
Your analysts deserve something better than copy-pasting SQL.
## tldr: AQL vs SQL
| **Category** | **SQL** | **AQL** |
|---|---|---|
| **Query Construction** | Must specify joins, GROUP BY, ORDER BY for every query. Business intent buried in mechanical plumbing. | Relationships defined once. Queries express only what you want to know. |
| **Metric Reusability** | Metrics are anonymous expressions embedded in queries. Must copy-paste and modify for each use. | Metrics are named, first-class objects. Define once, reference and compose freely. |
| **Metric Composition** | No native way to build metrics from other metrics. Each variant is a standalone query. | Pipe operator enables chaining: `revenue \| where(...)`, `revenue \| of_all(...)`. New metrics compose from existing ones. |
| **Percent of Total** | Requires nested window functions: `SUM(SUM(...)) OVER ()` | Single function: `revenue \| of_all(dimension)` |
| **Time Comparisons** | Self-joins on CTEs with date arithmetic. Must rewrite for each comparison period. | Single function: `revenue \| relative_period(date, interval(-1 year))` |
| **Nested Aggregations** | Subqueries or CTEs for each aggregation level. Deep nesting becomes unreadable. | Pipe chain: `model \| group(...) \| select(...) \| aggregate()` |
| **Single Source of Truth** | Metric definitions scattered across N queries. Changes require finding and updating all copies. | One definition; all derived metrics update automatically. |
**Bottom Line:** SQL is a table query language trying to serve as a metrics layer. AQL is a metrics-first language designed specifically for analytics composition.
---
## AQL Best Practices
This page collects practical advice for writing AQL well. Unlike [AQL Validation Rules](/as-code/aql/validation-rules) (which lists hard constraints), these are recommendations. Following them keeps your semantic layer maintainable as it grows.
## Model design
**Define a primary key on every model**
Not strictly required, but strongly recommended. A primary key lets AQL determine the grain of your data, drop unnecessary `GROUP BY` clauses, and support [Single Model Conditions](/reference/aql/where#single-model-condition). Without one, queries are less efficient and more prone to fan-out.
```aml
// ✅ Good
Model orders {
dimension id {
type: 'number'
primary_key: true
}
}
// ⚠️ Works, but no grain information
Model orders {
dimension id { type: 'number' }
}
```
## Metric design
**Don't reference dimensions without aggregating them**
A metric that returns a raw dimension value is fragile. It only works when that dimension is also in the explore, and end users typically expect a metric to be a single number. Always aggregate.
```aml
// ✅ Good
metric total_revenue {
definition: @aql sum(orders.amount) ;;
}
// ❌ Bad: returns raw values, breaks when dimension isn't grouped
metric total_revenue {
definition: @aql orders.amount ;;
}
```
**Don't hard-code time periods or dimensions into metric definitions**
Metrics should adapt to whatever grouping the end user chooses. Hard-coding a `group(... | month())` inside the metric turns it into a *table* rather than a scalar and locks downstream reports into that grain. Use grouping inside a metric only when the grouping is essential to the calculation itself (e.g., nested aggregation).
```aml
// ❌ Bad: returns a table of monthly values, not a single number
metric monthly_sales {
definition: @aql
orders
| group(orders.created_at | month())
| select(sum(orders.amount))
;;
}
// ✅ Good: let the user pick the time grain in the report
metric sales {
definition: @aql sum(orders.amount) ;;
}
// ✅ Good: grouping is essential because we want avg-of-monthly-totals
metric avg_monthly_sales {
definition: @aql
orders
| group(orders.created_at | month())
| select(sum(orders.amount))
| avg()
;;
}
```
**Reuse metrics instead of duplicating logic**
Build complex metrics from simpler ones. One source of truth means one place to fix bugs.
```aml
// ✅ Good
metric revenue {
definition: @aql sum(order_items.amount) ;;
}
metric revenue_last_month {
definition: @aql revenue | relative_period(orders.created_at, interval(-1 month)) ;;
}
metric revenue_vs_last_month {
definition: @aql revenue - revenue_last_month ;;
}
// ❌ Bad: duplicating the base logic
metric revenue_vs_last_month {
definition: @aql
sum(order_items.amount)
- (sum(order_items.amount) | relative_period(orders.created_at, interval(-1 month)))
;;
}
```
**Document complex logic**
Add a `description` for any metric whose definition isn't self-explanatory. Future you, and every downstream analyst, will thank you.
```aml
metric customer_churn_rate {
label: 'Customer Churn Rate'
description: 'Percentage of customers who cancelled in the period'
definition: @aql
safe_divide(
(count(users.id) | where(users.status == 'churned')) * 100,
count(users.id) | where(users.created_at < first(users.period_param))
)
;;
}
```
## Data quality
**Use `safe_divide` instead of `/` for ratios**
`safe_divide` returns null when the denominator is zero. Plain `/` raises a runtime error.
```aml
// ✅ Good
metric average_order_value {
definition: @aql safe_divide(revenue, order_count) ;;
}
// ❌ Bad: explodes when order_count is 0
metric average_order_value {
definition: @aql revenue / order_count ;;
}
```
**Wrap counts in `coalesce` when "no rows matched" should mean zero**
Unlike SQL, AQL returns null (not 0) when a filter matches no rows. If your visualization expects 0, use `coalesce`.
```aml
// ✅ Good: returns 0 when no completed orders exist
metric completed_orders {
definition: @aql
coalesce(
count(orders.id) | where(orders.status == 'completed'),
0
)
;;
}
// ❌ Bad: returns null, which charts handle inconsistently
metric completed_orders {
definition: @aql count(orders.id) | where(orders.status == 'completed') ;;
}
```
## Performance
**Use PreAggregates for frequently accessed or expensive metrics**
PreAggregates pre-compute and store aggregated data, dramatically reducing query latency. They're especially valuable for dashboard metrics over large fact tables, or for metrics that involve complex multi-step calculations.
Start with [Aggregate Awareness](/docs/aggregate-awareness/quick-start) for the concept, then [Building Multiple PreAggregates](/as-code/aml/use-cases/build-multiple-pre-aggregates) for the patterns.
## Common pitfalls
### Don't pick a wrong source table when one is needed
In most cases, AQL infers the source table from the expression and you don't need to specify it. When you *do* specify one (because the expression mixes models), make sure it matches the grain you're measuring.
```aml
// ✅ Good: let AQL infer
metric order_count {
definition: @aql count(orders.id) ;;
}
// ✅ Good: explicit because fields span two models
metric revenue {
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
// ❌ Bad: explicit source contradicts what's being counted
metric order_count {
definition: @aql count(order_items, orders.id) ;;
// Counts order_items rows, so an order with 5 items is counted 5 times
}
```
Rule of thumb: let AQL infer unless fields come from multiple models. When you do specify, the source table is the grain you're aggregating over.
### Don't assume null behaves like SQL
AQL returns null (not 0) when a filter matches nothing. Plan for that with `coalesce` or `safe_divide` where it matters: see [Data quality](#data-quality) above.
## See also
- [AQL Validation Rules](/as-code/aql/validation-rules): hard constraints
- [Order of Operations](/as-code/aql/order-of-operations): when filters and metrics evaluate
- [Cookbook](/as-code/aql/cookbook/metrics-by-example): worked examples of the patterns above
---
## Cohort Retention
:::info Options to build a retention heatmap in Holistics
We support multiple ways to build a retention heatmap. Here's a quick comparison to help you pick the right one:
| Option | Custom colors | Auto color scale* | Maintenance |
|--------|---------------|-------------------|-------------|
| [Built-in, legacy Retention Heatmap](/docs/charts/cohort-retention) | No | Yes | Low -- built-in visualization |
| **Pivot Table** with conditional formatting *(this page, recommended)* | Yes | Coming soon | Low -- built-in visualization |
| [Dynamic Content Block](/docs/charts/dynamic-content-blocks/gallery/retention-heatmap) | Yes | Yes | Higher -- you maintain the HTML/CSS yourself |
**(*) Auto color scale**: Color intensity adjusts automatically to the current data range. Without it, a heatmap built for a max of 1,000 will look washed out when a user's data only goes up to 80, or when a time filter reduces the range.
:::
## Introduction
**Cohort Retention Analysis** analyzes the continued engagement of specific user groups over time, helping businesses understand and improve customer loyalty and long-term user activity.
We will provide a step-by-step walkthrough on how to build a report in Holistics that demonstrates the classic cohort retention.
## Setup
In order to gain insights into the lifetime value of groups of users who were acquired for each particular period of time, our objective is to define and analyze Acquisition Cohorts.
For this tutorial, we will be using an e-commerce dataset that consists of two models: `orders` and `users`.
Before diving into the implementation, let's take a quick look at our dataset setup.
```aml
// orders.model.aml
Model orders {
...
dimension id {...}
dimension user_id {}
dimension created_at {}
}
// users.model.aml
Model users {
...
dimension id {...}
dimension name {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [orders, users]
relationships: [
relationship(orders.user_id > users.id, true)
]
// Define the Cohort
dimension acquisition_month_cohort {
model: users
type: 'date'
label: 'Acquisition Month Cohort'
// highlight-next-line
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
// Calculate the Total Users are in this Cohort
metric total_users {
label: "Untitled metric"
type: "number"
// highlight-next-line
definition: @aql count(users.id);;
}
// Define the Month Number
dimension month_no {
model: orders
label: 'Month Number'
type: 'number'
// highlight-next-line
definition: @aql date_diff('month', orders.cohort_month, orders.created_at | month());;
}
// How many users of the cohort are still active in consecutive months
metric retention {
label: 'Retention'
type: 'number'
// highlight-next-line
definition: @aql (total_users*1.0) / (total_users | of_all(orders.month_no));;
}
}
```
_Sample data_
## High-level flow
1. **Define Acquisition Cohort:** Determine the period and timeframe when each customer was acquired. This period could be daily, monthly, yearly, or any other suitable duration based on your business needs.
2. **Define the metric:** Define the metric you want to observe for each cohort
3. **Define the retention logic:** How many users of the cohort are still active in subsequent months
## Implementation
VIDEO
### 1. Define Acquisition Cohort
Let’s define the acquisition cohort as the month that users made their first purchase.
Since 1 user can make multiple purchases on different days, to find each user's first order date, obtain the earliest order created date (in the `Orders` model) and dimensionalize it by associating it with the User ID in the `Users` model. This can be done by using `min()` function and [`dimensionalize()`](/reference/aql/dimensionalize).
```aml
Dataset e_commerce {
(...)
dimension acquisition_month_cohort {
model: users
type: 'date'
label: 'Acquisition Month Cohort'
// highlight-next-line
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
}
```

### 2. Define the Metrics (or the Cohort Size)
You will then be able to find out how many users in the Acquisition Cohort.
First, calculate the `Total Number of Users` by simply using `count()` function.
```aml
Dataset e_commerce {
(...)
dimension acquisition_month_cohort {...}
metric total_users {
label: 'Total Users'
type: 'number'
// highlight-next-line
definition: @aql count(users.id);;
}
}
```
And then, to find out how many users are in this Acquisition Cohort, you will use the combination of **dimension** `Acquisition Month Cohort` with the **metric** `Total Users`

### 3. Define the retention logic
:::info
**What is considered retention**:
* If first-time user A made an order on Week 1, and returns to buy more stuffs the next weeks, she is a returned user.
* If user B also made an order on Week 1 and does not return the following weeks, she's a bounced user, that basically means you lose her as a user.
:::
First, calculate the **Month since Acquisition** by using the [`date_diff()`](/reference/aql/time-intelligence-functions#date_diff) function.
This dimension is necessary to determine how many users from a specific acquisition cohort return in subsequent months.
```aml
Dataset e_commerce {
(...)
dimension acquisition_month_cohort {...}
metric total_users {...}
dimension month_no {
model: orders
label: 'Month Number'
type: 'number'
// highlight-next-line
definition: @aql date_diff('month', users.acquisition_month_cohort, (orders.created_at | month()));;
}
}
```
This can be best illustrated by using Pivot Table where
- The **Row** is the Dimension `Acquisition Month Cohort`
- The **Column** is the Dimension `Month Number`
- The **Values** is the Metric `Total Users`

`Month Number 0` represents the `Cohort Size`, which calculates the total number of users who made their first purchase in the `Acquisition Month Cohort`.
`Month Numbers 1, 2, and 3` show how many users from that cohort continue to make purchases in the `subsequent months 1, 2, and 3`, respectively.
Finally, create a `retention` metric to measure the [percentage of total](/as-code/aql/cookbook/aql-percent-of-total) users retained in each _acquisition cohort_ for each month.
```aml
Dataset e_commerce {
(...)
dimension acquisition_month_cohort {...}
metric total_users {...}
dimension month_no {...}
metric retention {
label: 'Retention'
type: 'number'
// highlight-next-line
definition: @aql (total_users*1.0) / (total_users | of_all(orders.month_no));;
}
}
```

---
We have covered all the foundational concepts required to calculate metrics for the acquisition cohort, and you are now equipped to create even more powerful cohort analysis reports to present to your stakeholders in Holistics.
---
## Cumulative Metrics
## Introduction
In business analytics, aside from typical aggregation metrics, cumulative metric is an indispensable tool to understand the performance of your business over time.
In this guide, we will try to answer the following questions about a fictional ecommerce business:
- How does the company’s cumulative revenue look like over months?
- How does the cumulative average order value of each category look like?
We will work with the familiar `ecommerce` dataset, and use the `running_total()` function to answer these questions.
## Example 1: Cumulative Total Revenue over Months
### Setup
In this example, the `ecommerce` dataset will contain only the `orders`, `order_items`, and `products` models.
```aml
// orders.model.aml
Model orders {
...
dimension id {...}
dimension user_id {...}
dimension created_at {...}
dimension order_month {
label: 'Created At Month'
type: 'date'
definition: @aql date_trunc(orders.created_at, 'month') ;;
}
}
// order_items.model.aml
Model order_items.model.aml {
...
dimension order_id {...}
dimension product_id {...}
dimension quantity {...}
}
// products.model.aml
Model products {
...
dimension id {...}
dimension price {...}
dimension category_id {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [orders, order_items, products]
relationships: [
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true)
]
metric revenue {
label: 'Revenue'
type: 'number'
hidden: false
definition: @aql order_items | sum(order_items.quantity * products.price);;
}
metric cumulative_revenue {
label: 'Cumulative Revenue by Month'
type: 'number'
definition: @aql running_total(revenue, orders.created_at_month) ;;
}
}
```
### High-level flow
1. **Create the normal aggregation metric:** In the `ecommerce` dataset, we create a simple `revenue` metric that calculate order value by summing over `order_items.quantity` multiplied by `products.price`.
2. **Prepare the running dimension:** we will create a `created_at_month` dimension in the `orders` model. This will be used in the final running metric.
3. **Create the cumulative metric:** We use the `running_total()` to run the `revenue` metric along the dimension.
### Implementation
#### 1. Create the normal sum metric
In the definition file of the `ecommerce` dataset, we can easily create a `revenue` metric that combines fields from two different models (`order_items` and `products`) without the need to pre-join them:
```aml
Dataset ecommerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
}
```
#### 2. Prepare the running dimension
When we first created the `orders` table model, Holistics automatically scanned the table and pre-create model fields representing the physical table columns.
We already have `created_at` as the time dimension at the lowest grain (seconds), but we want a time dimension with a coarser grain (month). We can use the AQL function `date_trunc()` to truncate the timestamp values to month grain:
```aml
Model orders {
type: 'table'
label: 'Orders'
...
dimension created_at {
label: 'Created At'
type: 'datetime'
hidden: false
definition: @sql {{ #SOURCE.created_at }};;
}
dimension created_at_month {
label: 'Created At Month'
type: 'datetime'
definition: @aql date_trunc(orders.created_at, 'month') ;;
}
}
```
#### 3. Create the cumulative metric
Finally, we can use the `running_total()` function to create a Cumulative Revenue metric that will gradually add up revenue after each month:
```aml
Dataset ecommerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
metric cumulative_revenue {
label: 'Cumulative Revenue by Month'
type: 'number'
definition: @aql running_total(revenue, orders.created_at_month) ;;
}
}
```
## Example 2: Cumulative Average Order Value over Months of each Category
### Setup
Sometimes, simply looking at how a metric change over time is not enough - you may want to see how the metric differs between different groups. For example, you may ask: “How does the monthly cumulative average order value differs between categories?”
In this example, we will add the `categories` model to the `ecommerce` dataset.
```aml
// orders.model.aml
Model orders {
...
dimension id {...}
dimension user_id {}
dimension created_at {}
// note that metrics defined in model are called measures
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
}
// order_items.model.aml
Model order_items.model.aml {
...
dimension order_id {...}
dimension product_id {...}
dimension quantity {...}
}
// products.model.aml
Model products {
...
dimension id {...}
dimension price {...}
dimension category_id {...}
}
// categories.model.aml
Model categories {
...
dimension id {...}
dimension name {...}
dimension parent_id {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [orders, order_items, products, categories]
relationships: [
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(products.category_id > categories.id, true)
]
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
metric average_order_value {
label: 'AOV'
type: 'number'
definition: @aql safe_divide(revenue, orders.total_orders) ;;
}
metric cumulative_aov {
label: 'Cumulative AOV'
type: 'number'
definition: @aql running_total(average_order_value, orders.created_at_month) ;;
}
}
```
### High-level Flow
1. **Create the normal average metric:** In the `ecommerce` dataset, we create a simple `average_order_value` by dividing the previously created `revenue` metric with the `orders.count_orders` metric.
2. **Create the cumulative metric:** We use the `running_total()` to run the `average_order_value` metric along the `orders.created_at_month` dimension. In the final analysis, we will also include the `categories.name` dimension to see the effect.
### Implementation
#### 1. Create the normal average metric
In the `orders` model, we create a `total_orders` metric that count all the orders:
```aml
Model orders {
...
// note that metrics defined in model are called measures
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
}
```
To create the `average_order_values` metric, we can actually reuse the `revenue` metrics we created earlier instead of writing the full calculations again:
```aml
Dataset ecommerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
metric average_order_value {
label: 'AOV'
type: 'number'
definition: @aql safe_divide(revenue, orders.total_orders) ;;
}
}
```
#### 2. Create the cumulative metric
Similar to how we created the `cumulative_revenue` metric, we use the `running_total()` function to run the `average_order_value` metric along `orders.created_at_month` dimension.
```aml
Dataset ecommerce {
...
metric average_order_value {
label: 'AOV'
type: 'number'
definition: @aql safe_divide(revenue, orders.total_orders) ;;
}
metric cumulative_aov {
label: 'Cumulative AOV'
type: 'number'
definition: @aql running_total(average_order_value, orders.created_at_month) ;;
}
}
```
Since the `average_order_value` metric is only run against the `created_at_month` dimension, the `cumulative_aov` metric is partitioned by the `categories.name` dimension, which is what we wanted:
Producing the cumulative average this way is similar to dividing the `cumulative_revenue` metric by a `cumulative_orders` metric:
---
## Customer Order Frequency
## Introduction
This guide builds a **cumulative order-frequency distribution**: a chart that answers "what percent of our orders contain ≤ N items?" The X-axis is quantity-per-order, the Y-axis is order count, and a secondary line plots the running percentage.
Along the way, it walks through four reusable AQL building blocks: `dimensionalize`, percent-of-total, nested aggregation, and `running_total`, all chained on top of each other.
## Video Tutorial
VIDEO
## Setup
For this use case, we will work with `order_items` and `orders` tables from `e_commerce` datasets. `orders_items` has an N-1 relationship with orders
```aml
// order_items.model.aml
Model order_items {
...
dimension id {...}
dimension name {...}
}
// orders.model.aml
Model orders {
...
dimension id {...}
dimension created_at {}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [order_items, orders]
relationships: [
relationship(order_items.user_id > orders.id, true)
]
dimension cohort_quantity_per_order {
model: orders
label: "Cohort Quantity per Order"
type: "number"
definition: @aql order_items
| sum(order_items.quantity)
| dimensionalize(orders.id)
;;
}
metric count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id);;
}
metric pot_orders {
label: "% Orders"
type: "number"
definition: @aql (count_orders*1.0) / (count_orders | of_all(orders)) ;;
}
metric cumulative_percent_order_quantity {
label: "Cumulative % Orders"
type: "number"
definition: @aql orders
| group(cohort_quantity_per_order)
| sum(pot_orders)
| running_total(cohort_quantity_per_order)
;;
}
}
```
## High-level flow
1. **Dimensionalize the metric**: We already have a `quantity` dimension in `order_items`, and we’ll extract the same dimension for `orders` without making any changes upstream. This dimension will serve as the X-axis later on.
2. **Create `Count Orders` metric**: This metric will be used for the Y-Axis bar
3. **Create percent of total metric**: This metric will be used to calculate cumulative percent.
4. **Create nested aggregation and cumulative metric** : Once we have `Percent of Total` metric, we can use the `running_total` function to compute `Cumulative %` by running it on `Quantity per Order` dimension. This metric will be used for the second Y-Axis line
## Implementation
### 1. Dimensionalize the metric
Traditionally, in order to re-use an aggregation as a regular field within a model, the process requires pre-calculation in upstream modeling before bringing it to BI tool or either creating an additional Query Model at the BI layer. This constraint leads to a coupling between the BI and modeling layers.
In AQL, you can leverage `dimensionalize` along with cross-model calculation capability directly at the BI layer to bypass that constraint. In the definition of `cohort_quantity_per_order` below, `dimensionalize` helps perform `sum(orders_item.quantity)` expression to the specific grain of `orders` , without being coerced to a scalar value like other metrics. Due to this reason, we’re allowed to materialize this expression as dimension
```aml
Dataset e_commerce {
(...)
dimension cohort_quantity_per_order {
model: orders
label: "Cohort Quantity per Order"
type: "number"
definition: @aql order_items
| sum(order_items.quantity)
| dimensionalize(orders.id)
;;
}
}
```
### 2. Create `Count Orders` metric
This metric will be used for the Y-Axis bar to view the `Count Orders` of `Quantity per Order`
```aml
Dataset e_commerce {
(...)
dimension cohort_quantity_per_order {...}
metric count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id);;
}
}
```
### 3. Create percent of total metric
Even though we don’t use [Percent of Total](/as-code/aql/cookbook/aql-percent-of-total) directly for this use case, we still need this metric to define the `Cumulative % Orders` metric later on
```aml
Dataset e_commerce {
...
dimension cohort_quantity_per_order {...}
metric count_orders {...}
metric pot_orders {
label: "% Orders"
type: "number"
definition: @aql (count_orders*1.0) / (count_orders | of_all(orders)) ;;
}
}
```
### 4. Create cumulatively nested aggregation metric
This metric will be used to draw a percentage line on our combination chart. We’ll utilize running_total to calculate sum of `% Orders`
```aml
Dataset e_commerce {
...
dimension cohort_quantity_per_order {...}
metric count_orders {...}
metric _pot_orders {...}
metric cumulative_percent_order_quantity {
label: "Cumulative % Orders"
type: "number"
definition: @aql orders
| group(cohort_quantity_per_order)
| sum(orders.pot_orders)
| running_total(cohort_quantity_per_order)
;;
}
}
```
### 5. Bring it together
---
## Moving Average
Moving averages smooth out fluctuations in a time series so the underlying trend is easier to see. This page builds a 3-month moving average of monthly revenue two ways: with `trailing_period()` and with `window_avg()`. It explains when to pick which.
Uses the [shared e-commerce schema](/as-code/aql/cookbook/metrics-by-example#shared-e-commerce-schema): `orders`, `order_items`, `products`.
## Which function to use?
| | `trailing_period()` | `window_avg()` |
|---|---|---|
| **Operates on** | Calendar time | Table rows |
| **Handles gaps in data** | Yes. uses the calendar, not the rows | No. Uses adjacent rows regardless of dates |
| **Mixed time grains** (e.g. metric defined on day, viz on month) | Yes | No. viz grain must match |
| **Affected by visual filters** | No. Computed before filters | Yes. Only sees visible rows |
For example, with gaps in the data:
| Month | Revenue | `trailing_period` (3M) | `window_avg` (3 rows) |
|-------|---------|---------|----------|
| Jan | 100 | 100 | 100 |
| Mar | 100 | 50 | 100 |
| Jun | 100 | 50 | 100 |
`trailing_period` divides Mar's revenue by 3 because Feb and Apr are empty months. `window_avg` averages the three visible rows regardless of how far apart they are.
Default to `trailing_period()` for time-based moving averages. Reach for `window_avg()` when you genuinely want row-based logic (e.g. last-3-orders rather than last-3-months).
## Setup
Start with a plain revenue metric on the e-commerce dataset:
```aml
Dataset e_commerce {
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
}
```
## Option 1: `trailing_period()`
`trailing_period()` re-aggregates `revenue` over a moving calendar window. Two things to decide:
- **The time dimension**: the field that defines "which period a row belongs to." Use `orders.created_at` (no need to convert to month grain; the interval argument handles that).
- **The window**: `interval(3 months)` for a 3-month trailing window.
Because `trailing_period()` returns the *sum* over the window, divide by 3 to get the average:
```aml
metric moving_avg_revenue_3m_tp {
label: '3M Moving Avg. Revenue (trailing_period)'
type: 'number'
definition: @aql trailing_period(revenue, orders.created_at, interval(3 months)) / 3 ;;
}
```
The first two rows look low because only 1 and 2 months of revenue exist within their windows. Filter them out in the visualization if that's confusing.
## Option 2: `window_avg()`
`window_avg()` averages adjacent rows. You need to tell it:
- **The frame**: `-2..0` means "from 2 rows back through the current row" (3 rows total).
- **The order**: `orders.created_at | month()` so rows are ordered by month before the window is applied.
```aml
metric moving_avg_revenue_3m_wa {
label: '3M Moving Avg. Revenue (window_avg)'
type: 'number'
definition: @aql window_avg(revenue, -2..0, order: orders.created_at | month()) ;;
}
```
The first two rows average 1 and 2 rows respectively. To NULL them out instead, gate on `window_count`:
```aml
metric moving_avg_revenue_3m_wa {
label: '3M Moving Avg. Revenue (window_avg)'
type: 'number'
definition: @aql case(
when: window_count(revenue, -2..0, order: orders.created_at | month()) < 3,
then: null,
else: window_avg(revenue, -2..0, order: orders.created_at | month())
) ;;
}
```
## Visualize
Plot revenue and the moving average together. The smoothed line makes the trend much easier to read:
## See also
- [`trailing_period`](/reference/aql/trailing_period): function reference
- [`window_avg`](/reference/aql/window_avg): function reference
- [Cumulative Metrics](/as-code/aql/cookbook/aql-cumulative-metrics): running totals and accumulation
- [Period Comparison](/as-code/aql/cookbook/aql-period-comparison): YoY, QoQ, and CAGR
---
## Multi-Period KPI Summary
## Introduction
Stakeholders often want a single summary view showing key **business metrics side-by-side across several time windows**, much like an executive scorecard or a KPI summary sheet in a spreadsheet.
For example, you might want to see GMV, NMV, AOV, total orders, delivered orders, cancelled orders, and cancellation rate for the current week, month-to-date (MTD), MTD last year, year-to-date (YTD), and YTD last year, all in one compact table.
This pattern works for any type of metric, whether it's a monetary sum like GMV, a count like Total Orders, a ratio like AOV, or a percentage like Cancelled Order Ratio.
In this guide, we will walk you through how to build this kind of report in Holistics by combining a standalone query model, conditional metrics, and a [Pivot Table](/docs/charts/pivot-table).
## High-level flow
Here is how the pieces fit together:
1. **Create a Time Type query model.** We build a standalone [query model](/docs/query-models) that generates one row per time period label (for e.g., `Week`, `MTD`, `MTD LY`, `YTD`, `YTD LY`). This model doesn't need any relationships to your fact tables.
2. **Define conditional metrics using `case()`.** For each metric (e.g., GMV), we write a `case()` expression that branches on the Time Type value. Each branch applies `where()` with a [natural time expression](/docs/datetimes/relative-dates) like `@(this week)` or `@(this year to today)`. For "last year" variants, we also apply `relative_period()` to shift the time window back by one year.
3. **Visualize in a Pivot Table.** We place the Time Type dimension in the **Columns** shelf and the metrics as **Values** (displayed as rows). This gives us the familiar spreadsheet-style layout.
## Setup
For this guide, we use an `ecommerce` dataset with `orders`, `order_items`, `products`, `users`, and a `date_dim`. We also define several base metrics that we will later wrap with time-period logic.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
models: [
orders, users, order_items, products, date_dim
]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(orders.created_date > date_dim.date_key, true)
// No relationship for time_type: it's intentionally standalone
]
// --- Base metrics (these are what we'll wrap with time-period logic) ---
metric gmv { }
metric nmv { }
metric total_orders { }
metric aov { }
metric total_delivered_orders { }
metric total_cancelled_orders { }
metric cancelled_order_ratio { }
}
```
With these base metrics in place, we are ready to create the time-period variants.
## Implementation
### Step 1: Create the Time Type query model
The first step is to create a standalone query model **that generates the time period labels**. This model uses a simple SQL `VALUES` clause to produce one row per period. It doesn't need a relationship to your fact tables, as it serves as a virtual dimension table that provides column headers for the pivot.
We prefix each label with a number (e.g., `1. Week`, `2. MTD`) to control the sort order of columns in the final pivot table.
```aml title="time_type.model.aml"
Model time_type {
type: 'query'
label: 'Time Type'
data_source_name: 'your_data_source'
query: @sql
SELECT time_type FROM (VALUES
('1. Week'),
('2. MTD'),
('3. MTD LY'),
('4. YTD'),
('5. YTD LY')
) AS t(time_type) ;;
dimension time_type {
label: 'Time Type'
type: 'text'
definition: @sql {{ #SOURCE.time_type }} ;;
}
}
```
:::info
The `VALUES` syntax works on most modern databases (PostgreSQL, BigQuery, Snowflake, etc.). If your database doesn't support it, you can use `UNION ALL` instead:
```sql
SELECT '1. Week' AS time_type
UNION ALL SELECT '2. MTD'
UNION ALL SELECT '3. MTD LY'
UNION ALL SELECT '4. YTD'
UNION ALL SELECT '5. YTD LY'
```
:::
### Step 2: Add the Time Type model to the dataset
Once the model is created, add `time_type` to the `models` list of your dataset. You don't need to define any relationship for it
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [
orders, users, order_items, products, date_dim,
// highlight-next-line
time_type // standalone: no relationship needed
]
relationships: [
// your existing relationships stay the same
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
...
]
}
```
### Step 3: Define conditional metrics
Now we define metrics that compute different values depending on which time period is active. The `case()` function branches on `time_type.time_type`, and each branch uses `where()` with a [natural time expression](/docs/datetimes/relative-dates) to filter the date range. For "last year" variants, we pipe through `relative_period()` to shift the window back by one year.
Here is what each branch does:
| Branch | Time Expression | Meaning |
|--------|----------------|---------|
| `1. Week` | `@(this week)` | Current calendar week |
| `2. MTD` | `@(this month to today)` | Start of current month through today |
| `3. MTD LY` | `relative_period` + `@(this month to today)` | Same month-to-date window, shifted 1 year back |
| `4. YTD` | `@(this year to today)` | Start of current year through today |
| `5. YTD LY` | `relative_period` + `@(this year to today)` | Same year-to-date window, shifted 1 year back |
Here are two examples (`GMV` and `Total Orders`), showing how the pattern works:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
metric gmv_time_compare {
label: 'GMV (time compare)'
type: 'number'
definition: @aql case(
when: time_type.time_type == "1. Week"
, then: gmv | where(date_dim.date_key match @(this week))
, when: time_type.time_type == "2. MTD"
, then: gmv | where(date_dim.date_key match @(this month to today))
, when: time_type.time_type == "3. MTD LY"
, then: gmv | relative_period(date_dim.date_key, interval(-1 year)) | where(date_dim.date_key match @(this month to today))
, when: time_type.time_type == "4. YTD"
, then: gmv | where(date_dim.date_key match @(this year to today))
, when: time_type.time_type == "5. YTD LY"
, then: gmv | relative_period(date_dim.date_key, interval(-1 year)) | where(date_dim.date_key match @(this year to today))
) ;;
format: "[$]#,###0"
}
metric total_orders_time_compare {
label: 'Total Orders (time compare)'
type: 'number'
definition: @aql case(
when: time_type.time_type == "1. Week"
, then: total_orders | where(date_dim.date_key match @(this week))
, when: time_type.time_type == "2. MTD"
, then: total_orders | where(date_dim.date_key match @(this month to today))
, when: time_type.time_type == "3. MTD LY"
, then: total_orders | relative_period(date_dim.date_key, interval(-1 year)) | where(date_dim.date_key match @(this month to today))
, when: time_type.time_type == "4. YTD"
, then: total_orders | where(date_dim.date_key match @(this year to today))
, when: time_type.time_type == "5. YTD LY"
, then: total_orders | relative_period(date_dim.date_key, interval(-1 year)) | where(date_dim.date_key match @(this year to today))
) ;;
format: "#,###"
}
// ... same pattern for nmv, aov, total_delivered_orders, cancelled_order_ratio, etc.
}
```
Notice how the two metrics are nearly identical, only the base metric name (`gmv` vs `total_orders`), the label, and the format differ. The `case()` structure and the `relative_period()` / `where()` logic are exactly the same. For "last year" branches, we apply `relative_period(date_dim.date_key, interval(-1 year))` first to shift the time window, then `where()` to filter the date range.
As you add more metrics, this **copy-paste pattern quickly becomes hard to maintain**.
#### Reduce repetition with AML Func
Instead of duplicating this block for every metric, we can use an [AML Function](/reference/aml/func) with [string interpolation](/reference/aml/string-interpolation) to extract the pattern into a reusable function.
Define the function in a separate file (or at the top of your dataset file):
```aml title="time_compare_func.aml"
Func time_compare(metric_name: String, metric_label: String, format: String) {
Metric {
label: '${metric_label} (time compare)'
type: 'number'
definition: @aql case(
when: time_type.time_type == "1. Week"
, then: ${metric_name} | where(date_dim.date_key match @(this week))
, when: time_type.time_type == "2. MTD"
, then: ${metric_name} | where(date_dim.date_key match @(this month to today))
, when: time_type.time_type == "3. MTD LY"
, then: ${metric_name} | relative_period(date_dim.date_key, interval(-1 year)) | where(date_dim.date_key match @(this month to today))
, when: time_type.time_type == "4. YTD"
, then: ${metric_name} | where(date_dim.date_key match @(this year to today))
, when: time_type.time_type == "5. YTD LY"
, then: ${metric_name} | relative_period(date_dim.date_key, interval(-1 year)) | where(date_dim.date_key match @(this year to today))
) ;;
format: format
}
}
```
The function takes three parameters:
- `metric_name`: the name of the base metric to wrap (e.g., `'gmv'`, `'total_orders'`)
- `metric_label`: the display label (e.g., `'GMV'`, `'Total Orders'`)
- `format`: the number format string
Inside the function, `${metric_name}` and `${metric_label}` are injected into the metric definition via [string interpolation](/reference/aml/string-interpolation).
Now each metric declaration becomes a single line:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
// time comparison metrics
metric gmv_time_compare: time_compare('gmv', 'GMV', '[$]#,###0')
metric nmv_time_compare: time_compare('nmv', 'NMV', '[$]#,###0')
metric aov_time_compare: time_compare('aov', 'AOV', '[$]#,###0')
metric total_orders_time_compare: time_compare('total_orders', 'Total Orders', '#,###')
metric total_delivered_orders_time_compare: time_compare('total_delivered_orders', 'Total Delivered Orders', '#,###')
metric total_cancelled_orders_time_compare: time_compare('total_cancelled_orders', 'Total Cancelled Orders', '#,###')
metric cancelled_order_ratio_time_compare: time_compare('cancelled_order_ratio', 'Cancelled Order Ratio', '#,###0.00%')
}
```
Adding a new metric to the scorecard is now a one-liner, no more copy-pasting 15 lines of `case()` logic.
### Step 4: Build the pivot table
With the model and metrics in place, create an exploration and set up the visualization:
1. Add `time_type.time_type` to the **Columns** shelf.
2. Add your time-compare metrics (e.g., `gmv_time_compare`, `nmv_time_compare`, `aov_time_compare`, `total_orders_time_compare`, `cancelled_order_ratio_time_compare`) as **Values**.
3. Set the chart type to **Pivot Table**.
4. In the Values section, switch to **Show as rows** so each metric becomes its own row.
## Customization ideas
This pattern is flexible. Here are some ways you can adapt it:
- **Different time periods.** Add or replace `case()` branches for windows like `QTD`, `QTD LY`, `Last Week`, or `Last Month`. Just add corresponding rows in the `time_type` query model and matching `when:` branches in each metric.
- **More metrics.** Apply the same `case()` pattern to any base measure (revenue per active customer, refund rate, etc). Each new metric follows the identical structure.
- **Custom sort order.** The number prefixes (`1.`, `2.`, etc.) control column ordering in the pivot. Adjust or remove them depending on how you want the columns arranged.
---
## Nested Aggregation
A focused recipe for nesting aggregations across multiple models. For the underlying concept, see **[Learn AQL → Nested Aggregation](/as-code/aql/learn/nested-aggregation)**.
## The question
> Which country has the user with the highest Average Order Value (AOV)?
Three grains are in play:
- **Order item** (`order_items`): the raw fact rows.
- **User** (`users`): we aggregate AOV per user.
- **Country** (`cities.country_name`): we take the max user AOV per country.
## Models and relationships
```aml
Model order_items {
dimension id {...}
dimension user_id {...}
dimension product_id {...}
dimension quantity {...}
}
Model products { dimension id {...} dimension price {...} }
Model users { dimension id {...} dimension city_id {...} }
Model cities { dimension id {...} dimension country_name {...} }
Dataset e_commerce {
models: [order_items, products, users, cities]
relationships: [
relationship(order_items.user_id > users.id, true),
relationship(order_items.product_id > products.id, true),
relationship(users.city_id > cities.id, true),
]
}
```
## Step 1: the inner metric (AOV)
AOV is total order value divided by distinct order count:
```aml
metric aov {
label: 'AOV'
type: 'number'
definition: @aql
sum(order_items, order_items.quantity * products.price)
/ count_distinct(order_items.order_id)
;;
}
```
## Step 2: nest it at the user grain, then take the max
```aml
metric max_user_aov {
label: 'Max User AOV'
type: 'number'
definition: @aql
order_items
| group(users.id)
| select(aov)
| max()
;;
}
```
Walking through:
1. `order_items`: start from order items, the finest grain.
2. `| group(users.id)`: bucket by user.
3. `| select(aov)`: compute AOV per user.
4. `| max()`: take the largest of those per-user AOVs.
## Step 3: use it with a coarser dimension
In a report, drop `max_user_aov` alongside `cities.country_name`:
Because nested aggregation respects surrounding context, AQL evaluates the inner per-user AOV *within each country* and returns the country-level max. Same metric, no rewriting needed for new dimensions.
## See also
- [Learn AQL → Nested Aggregation](/as-code/aql/learn/nested-aggregation): the concept and the simpler "average monthly signups" example
- [Learn AQL → Cross-Model Queries](/as-code/aql/learn/cross-model): how AQL resolves the order_items → users → cities chain
---
## Percent of Total
:::info Alternative
This is the AQL-centric approach to calculate Percent of Total.
Holistics also provides a UI-friendly solution: [Percent of Total](/docs/percent-of-total)
:::
## Introduction
The Percent of Total analysis is a commonly used reporting technique that helps understand the contribution of each row value to the overall population.
In SQL, you have to divide your calculation into multiple CTEs (Common Table Expressions) and then cross-join those together to perform Percent of Total. Whereas with AQL, you can achieve this calculation in a more concise and reusable way by focusing on the business logic rather than the technical semantics.
:::tip Recommended Prerequisites
- [of_all](/reference/aql/of_all): Exclude your metric from being evaluated against certain dimensions/grains.
:::
## Video Tutorial
VIDEO
## High-level Flow
Suppose we want to see how each product's sales contribute to global sales.
To illustrate the calculation using AQL, we'll explore two different ways of performing it. First, we will break down the calculation into manageable steps or components. Then, we'll demonstrate how to write it as a single-line query.
## Setup
To make it simple, we'll be working on the `order_items` modeling definition only with the following fields.
:::tip Measures and Metrics
You may see the use of `measure` in code snippets throughout this article. In Holistics, metrics defined inside data models are syntactically referred to as `measure`. We will use the word "metric" outside of code snippets to avoid confusions. [Read more about Metrics here](/as-code/aql/learn/defining-a-metric)
:::
We'll show two equivalent ways to define `percent_of_total`. Pick whichever style suits the metric:
```aml
Model order_items {
...
dimension id {...}
dimension product {...}
dimension amount {...}
measure total_sales {
label: 'Total Sales'
type: 'number'
definition: @aql sum(order_items.amount) ;;
}
measure _total_sales_of_all {
label: 'Total Sales of All'
definition: @aql sum(order_items.amount) | of_all(order_items.id) ;;
hidden: true
}
measure percent_of_total {
label: "Percent of Total"
type: 'number'
definition: @aql (order_items.total_sales*1.0) / order_items._total_sales_of_all ;;
}
}
```
```aml
Model order_items {
...
dimension id {...}
dimension product {...}
dimension amount {...}
measure percent_of_total {
label: "Percent of Total"
type: 'number'
definition: @aql (sum(order_items.amount) * 1.0) / (sum(order_items.amount) | of_all(order_items));;
}
}
```
## Implementation
**1. Define the aggregation**
Calculate the total sales value for each product by defining the metric `total_sales`
```aml
Model order_items {
...
measure total_sales {
label: 'Total Sales'
type: 'number'
definition: @aql sum(order_items.amount) ;;
}
}
```
export const step_1_sql = `SELECT
products.name as "products->name"
sum(order_items.amount) as "order_items->total_sales"
FROM
order_items
LEFT JOIN
products
ON order_items.product_id = products.id
GROUP BY 1
`
export const step_1 = {
"fields": [
"Product",
"Total Sales"
],
"records": [
[
"Chocolate 2",
"2187"
],
[
"Samsung Tablet 2",
"101313"
],
[
"H&M T-shirt",
"25194"
],
[
"Face Treatment",
"4743"
],
[
"Adidas Shoes",
"38901"
],
[
"Xbox",
"102797"
],
[
"Jordan Shoes",
"18760"
],
[
"Bunk Bed",
"13893"
],
[
"AFC Biscuits",
"2350"
],
[
"Sony Headphone 1",
"10665"
],
[
"Sofa 1",
"50334"
],
[
"Body Scrub",
"211359"
],
[
"Flour 1",
"800"
],
[
"Converse Shoes",
"40575"
],
[
"Outdoor Sofa",
"24999"
],
[
"Bathroom Mirror",
"3960"
],
[
"Face Moisturizers",
"4576"
],
[
"Wii",
"24090"
],
[
"Cookies 2",
"6049"
],
[
"Face Cream",
"17365"
],
[
"Nokia C1",
"87307"
],
[
"High Cabinet",
"8432"
],
[
"Lacoste T-shirt",
"21994"
],
[
"Body Cleanser",
"161143"
],
[
"Reebox Shoes",
"21231"
],
[
"Ticwatch 1",
"74019"
],
[
"KitKat 1",
"1037"
],
],
"metadata": {
"aql": "explore {\n dimensions {\n products.name\n }\n measures {\n order_items.sales\n }\n}",
"executed_sql": "WITH \"aql__t3\" AS (\n SELECT\n (\"order_items\".\"quantity\" * \"products\".\"price\") AS \"value\",\n \"order_items\".\"product_id\" AS \"product_id\"\n FROM\n \"demo\".\"order_items\" \"order_items\"\n LEFT JOIN \"demo\".\"products\" \"products\" ON \"order_items\".\"product_id\" = \"products\".\"id\"\n)\nSELECT\n \"products\".\"name\" AS \"products->name\",\n SUM(\"order_items\".\"value\") AS \"order_items->sales\"\nFROM\n \"aql__t3\" \"order_items\"\n LEFT JOIN \"demo\".\"products\" \"products\" ON \"order_items\".\"product_id\" = \"products\".\"id\"\nGROUP BY\n 1\n",
"url": "https://play.amql.org/#code/uDritRdcsbtfekV2DrisS2OsH2FriisE2HtiVcRkZ2YtjYZgjsGxkDxYDtCxULrgtUVdfPTfl23tiZVjxKD2RtPxUGtZ2kxRFxbEuBxZF3ptMe3utkuBtgVsSrgntdRe2Wtk2o3LtVrnxXEtWifd2Xse20tcU3ktWxrG2B3M23tcxuDyRGxpFxrDxlExVEyfLxbFuFyjFtfyKbzvIyNRyFKxDD2C0tStg4mtUlTk5btZU0qGtfi2Hti2Otk2M2UxdHxcD0t7yULzlPyiK0wmzyIyKPyCK0tXzIF0rWxMH0p90pZygI0nmzsG0lwtlj0kR2PxVE0e9zTWyaH0YmzfF0XwtTZ2QtV0YK0mGxVD5D0X9zSUyZH0WmzeG0XarisErisHtmynD0WDsGtRdcrisSritT0gDtkrgsZlktPGsdtCDshnmrgsV2ysKGIrnxrEtU6DtR4ZtkjsHxpDxQNxJHyHHscxOG1nGtV3Vtdd3mtTVziGxqEtPjflxRDtPe2v1gExnEzYDxeE0BFtj2UtLz4G0IGsExNH21tekixQKtfi2sti6Wtk3BxSNxNItgi3PtlT4ixPHtl4X2bxLEtNytGti3r4Q1DDtjYZgy0KxWMsA0IFy5JxKD2RtPxUGzdFxbHuBxZFsErg2KtlVsBywHyKSzUI42tUyHG0HLxdIxcDyKg0rGyIVxMHyGh1VFyFO2PxVEx8j1FHyAIyOGxVD6Qx8Jx7EtN2FuFrisErisH2j2ntcjsHrisSrixOLz6LsGxTFsGtRdcxjDslxMErgxeLrguDyPE3ftgVsSrgntkRScVrnxRE4atS2oxSDsnxqKxYF2OtjTi4vz3ExeDxTG5ItRPjfliTVPe3pyLE0BExcFtfne3CxRHsYtYfcZj26tT4Y3BxeFyyFx0ItMrixlE2GsG2DzlL2NxqDypFtZ2m6wy6Drgtgi5NtlTk1ZD0FGzzKtAxaGrgshtUyiF0gJteld2htixUHtY243h0GDtWR6ztVxTG0eTxVEtWZe4KxUFsYtjhc3wuDrgjtDsntFCsbdGzJLuFFsTTxxEuFztQ0OHzrRsnxYEzp9zplzFJznZthlRe284GznQtBxYHzn9znizFJznZtm3htlVzkQtGxVEzhby2NtRy2D1VHtkVdjy3KsCrgtgifUlTk2U2JtZT3pzJN4HtRjli2Vtj3v5fzHQtEfk2XrgtDxbEzNs3UtdsAzRM0mFsBy7MuFrisErisHtd4c3ptjsHtTR2ltX2vtZ3nsGxSFsGtRdcrisSrislxMErgxdKziGzGH4dtS4azFFzxIsbxpJxXF28tjT2NtgzbGrnxTG2itRPjf526PtPe3nyKE2m31xcFtfne40xRHsYtYfcZj26tT4W3BxeFyxFx0ItMrixlE2GsG2DzjK2MxpDyoFtZ2ltejy5DrgtZUz4GzpKshtUyRF0OJte6z2ZtixUHtY2w3bzyDtW64tjVxTG0NTxVEtWZeZxUGsYtjh6uuDDrgjtDsntFCsbd4132uFFsTTxpEuFzVQ0HEzXQsmxUDzZO5ZuAtkzX9zXOy4FzZZtgRi2KtkP11StAxZFrg189zlkzHKzqNuFrisErisHtdf263dsHtTx7D3asGxOFsG5XtcrisSrislxMErgxZG0DGzmHtk5ItcVy6F0SIsbxlFxTFzjTtUR2ytPjfliTVP12ExYD2m3txcFtfne50xRHsYtYfcZj26tT4O3BxeFytFx0ItMrixlE2GsG2DzbG2IxlDykFtZ2htejy1Drg0sDzwFzhKshtU1tH0GHteld2ZtixUHtY2w3XzuDtWR6PtVxTG0JTxVEtWZey3DxUEsYtjh6iuDDrgjtDsntFCsbd4x32uFFsTTxpEuFzVQ0DEzXQsmxUDzZOtkVuAtkzX9zXOy4FzZZtTflekiuBtP2I3HzhQsbxcGrg2I2czp9zpgzLNzxNuFrisErisHtdxXD3gsHxmGtZ3gsGxRFsG5htcrisSrislxME0QHxcD0NGztI5Stc0DG0cOxoDxWFzvTtUR21tPj2mtiTVPe3lyJE2m3zxcFtfneVixRHsYtYfcZj26tT4U3BxeFywFx0ItMrixlE2GsG2DzhJ2LxoDynFtZ2ktejy4D0AD06Dz4FzpM2UyUF0QKtVuAtkxSHtYZU2ezzDtWR6dtVxTG0OTxVEtWZeZxUGsYtjh6tuDDrgjtDsntFCsbdGy4FuFFsTTxrEuFzZQ0KEzZQsmxUDzZ9zZgy4FzZZ43te23te2NtkPzjU68xeHrgzt9ztkzPPz3NuFrisErisHtdf3C3isHtfi2HtijsGxOFsG2qtcrisSrislxMErgxZG0LGzpI5Qtcz8G0aIsnxlFxTFzoTtUR2ytPjfliTVzMFxYD2m3txcFtfn4jtixRHsYtYfcZj26tT4O3BxeFytFx0ItMrixlE2GsG2DzbG2IxlDykFtZ2htejy1DrgtZUzwGzhKshtU1yH0GHteld2ZtixUHtY2w3MzuDtWR6PtVxTG0JTxVEtWZeZxUGsYtjh6iuDDrgjtDsntFCsbd4x32uFFsTTxpEuFzVQ5W6HtljzZQtDxWFzdOtkVuAtkzb9zbOy8HzfZ3RtV2htVUP2FzjQsbxaGrgsZzVIznHtUxWD3v3Dzr9zrOzLLzvZ5OtViP3vzsQtFxXDrgshtUzpOteld2etizn9znOzDIzkZ2K3Vtfle5JzlPscxYHzm9zmizFJzna4VtZm33uBtPRkkVdgjzvRxgHrgsZxgGz39z3jzVQ0CNuFrisErisHtdfxlDtjsHtgi2ItlTkjsGxQFsGtRdcrisSrislxMErgxbI0VGzxHtk5atcVzIF0kItAxnHxVFzwTtU4KtRPjfliTVPe3jyIE2m3xxcFtfne4sxRHsYtYf47tj26tT4S3BxeFyvFx0ItMrixlE2GsG2DzfI2KxnDymFtZ2jtejy3DrgtZUz0GzlKshtU18H0KHteld2ZtixUHtY2w3ZzwDtWR6XtVxTG0LTxVEtWZeZxUGsYtjh6ouDDrgjtDsntFCsbd4z32uFFsTTxpEuFzVQtT6ItVXfiuBtPzeSsbxbHrgzn9znkzLMzwZteR2KzpQsmxUDziO3MuAtkzg9zgOy4FzZZ2ItiTYRek0BEzgPslxbHrgshtUznOteld2itizp9zpOzLMzwZtg3WtT5SzqPtAxVEzk9zkiy8GzeZ3QtVRkVUP2FzjQsbxaGrgsZtkzoOtUxWD3u3Dzq9zqOzLLzvNuFrisErisHtdf273esHtl3htijsGxNFsGtRdcrisSrislxMErgxYF0FGznHtk5KtczkG0UItFxkExSFzhT0ZDtRPjfli6ztPe3d0sFtV3rxcFtfne26xRHsYtYfcZj26tT4M3BxeFysFx0ItMrixlE2GsG2DzZF2HxkDyjF14Dtejy0Drg62zuGzfKshtU1qH0EHteld2ZtixUHtY2w3WztDtWR6LtVxTG0ITxVEtWZeZxUGsYtjh6fuDDrgjtDsntFCsbd4w32uFFsTTxpEuFzVQ2FtXePlgP1NDtVzfQtDxcDrgtFgrgscxcDzpOxuEzn9znOzLNzxh2lzvYsZtkztS313Dzx9zxWzLDzvZ3BtijkPeR2QzvQsexaErgxaEzvO4tuA0EIzr9zrHzHLzrZ4ZzqXskxZDzp9zplzFKzoZtVdRZx0DzkOsdxVEzg9zggy6GzcZtSZikYPUR35zhQsaxaE2qxaDzmOxSLzm9zmHzHLzrZtX2I3GtiznQsfxWFzjO3JuAtkzj9zjOy8HzfZtT24uBtP3rzgQsbxXDrg2XzhOteld2ez1Izj9zjHzDIzkZtWlccPeR2PzmQsexZDrgxZEzoOtkVuAtkzm9zmOzFKzoNuFrisErisHtdf263d2H6dtTYRekjsGxRFsG2ttcrisSrislxMErgxcJ0HGzqI5Mtc0AG0WIslxoIxWFzsTtUR21tPjfl2ntVzVFxYD2m3zxcFtfne3CxRHsYtYfcZj26tT4U3BxeFywFx0ItMrixlE2GsG2DzhJ2LxoDynFtZ2Utejy4DrgtZUz2GznKshtU15H0MHteld2ZtixUHtY2w3azxDtWR6btVxTG0MTxVEtWZeZxUGsYtjh6ruDDrgjtDsntFCsbd4032uFFsTTxpEuFzVQ0GEzXQsmxUDzZOtkVuAtkzX9zXOy4FzZZtT22uBtP1zSsbxXDrg149zhkzDIzkNuFriuFsEritR2psb2y6W2DrisSri6ctgcfi5OzqF0EJtjz6ItgifUlTkj0zFyKH2F2LtRjl21xmJ3E4Qti3w3Wtd2ptj462ZxqH3y3xykHsctRk20tVksmyKDy5D2vtdfPVTfd2OtiT2R2mtT3S2k4XsetZ3AtA2qtYxjDsH2i2m6LsHyfMxTFsG3Gtc5Q"
}
}
export const step_2 = {
"fields": [
"Product",
"Total Sales",
"Total Sales of All"
],
"records": [
[
"Chocolate 2",
"2187",
"4391797"
],
[
"Samsung Tablet 2",
"101313",
"4391797"
],
[
"H&M T-shirt",
"25194",
"4391797"
],
[
"Face Treatment",
"4743",
"4391797"
],
[
"Adidas Shoes",
"38901",
"4391797"
],
[
"Xbox",
"102797",
"4391797"
],
[
"Jordan Shoes",
"18760",
"4391797"
],
[
"Bunk Bed",
"13893",
"4391797"
],
[
"AFC Biscuits",
"2350",
"4391797"
],
[
"Sony Headphone 1",
"10665",
"4391797"
],
[
"Sofa 1",
"50334",
"4391797"
],
[
"Body Scrub",
"211359",
"4391797"
],
[
"Flour 1",
"800",
"4391797"
],
[
"Converse Shoes",
"40575",
"4391797"
],
[
"Outdoor Sofa",
"24999",
"4391797"
],
[
"Bathroom Mirror",
"3960",
"4391797"
],
[
"Face Moisturizers",
"4576",
"4391797"
],
[
"Wii",
"24090",
"4391797"
],
[
"Cookies 2",
"6049",
"4391797"
],
[
"Face Cream",
"17365",
"4391797"
],
[
"Nokia C1",
"87307",
"4391797"
],
[
"High Cabinet",
"8432",
"4391797"
],
[
"Lacoste T-shirt",
"21994",
"4391797"
],
[
"Body Cleanser",
"161143",
"4391797"
],
[
"Reebox Shoes",
"21231",
"4391797"
],
[
"Ticwatch 1",
"74019",
"4391797"
],
[
"KitKat 1",
"1037",
"4391797"
],
[
"Umbrella",
"29855",
"4391797"
],
[
"Guess Jeans",
"13881",
"4391797"
],
[
"10 Deep T-shirt",
"9398",
"4391797"
],
[
"iPad 1",
"130244",
"4391797"
]
],
"metadata": {
"aql": "explore {\n dimensions {\n products.name\n }\n measures {\n order_items.total_sales,\n order_items._total_sales_of_all\n }\n}",
"executed_sql": "WITH \"aql__t3\" AS (\n SELECT\n (\"order_items\".\"quantity\" * \"products\".\"price\") AS \"amount\",\n \"order_items\".\"product_id\" AS \"product_id\"\n FROM\n \"demo\".\"order_items\" \"order_items\"\n LEFT JOIN \"demo\".\"products\" \"products\" ON \"order_items\".\"product_id\" = \"products\".\"id\"\n), \"aql__t1\" AS (\n SELECT\n \"products\".\"name\" AS \"products->name\",\n SUM(\"order_items\".\"amount\") AS \"sum_order_items->amount\"\n FROM\n \"aql__t3\" \"order_items\"\n LEFT JOIN \"demo\".\"products\" \"products\" ON \"order_items\".\"product_id\" = \"products\".\"id\"\n GROUP BY\n 1\n), \"aql__t4\" AS (\n SELECT\n SUM(\"order_items\".\"amount\") AS \"sum_order_items->amount\"\n FROM\n \"aql__t3\" \"order_items\"\n)\nSELECT\n \"aql__t1\".\"products->name\" AS \"products->name\",\n \"aql__t1\".\"sum_order_items->amount\" AS \"order_items->total_sales\",\n \"aql__t4\".\"sum_order_items->amount\" AS \"order_items->_total_sales_of_all\"\nFROM\n \"aql__t1\"\n CROSS JOIN \"aql__t4\"\n",
"url": "https://play.amql.org/#code/uDritRdcsbtfekV2DrisS2OsH2FriisE2HtiVcRkZ2YtjYZgjsGxkDxYDtCxULrgtUVdfPTfl23tiZVjxKD2RtPxUGtZ2kxRFxbEuBxZF3ptMe3utkuBtgVsSrgntdRe2Wtk2o3LtVrnxXEtWifd2Xse20tcU3ktWxrG2B3M23tcxuDyRGxpFxrDxlExVEyfLxbFuFyjFtfyKbzvIyNRyFKxDD2C0tStg4mtUlTk5btZU0qGtfi2Hti2Otk2M2UxdHxcD0t7yULzlPyiK0wmzyIyKPyCK0tXzIF0rWxMH0p90pZygI0nmzsG0lwtlj0kR2PxVE0e9zTWyaH0YmzfF0XwtTZ2QtV0YK0mGxVD5D0X9zSUyZH0WmzeG0XarisErisHtmynD0WDsGtRdcrisSritT0gDtkrgsZlktPGsdtCDshnmrgsV2ysKGIrnxrEtU6DtR4ZtkjsHxpDxQNxJHyHHscxOG1nGtV3Vtdd3mtTVziGxqEtPjflxRDtPe2v1gExnEzYDxeE0BFtj2UtLz4G0IGsExNH21tekixQKtfi2sti6Wtk3BxSNxNItgi3PtlT4ixPHtl4X2bxLEtNytGti3r4Q1DDtjYZgy0KxWMsA0IFy5JxKD2RtPxUGzdFxbHuBxZFsErg2KtlVsBywHyKSzUI42tUyHG0HLxdIxcDyKg0rGyIVxMHyGh1VFyFO2PxVEx8j1FHyAIyOGxVD6Qx8Jx7EtN2FuFrisErisH2j2ntcjsHrisSrixOLz6LsGxTFsGtRdcxjDslxMErgxeLrguDyPE3ftgVsSrgntkRScVrnxRE4atS2oxSDsnxqKxYF2OtjTi4vz3ExeDxTG5ItRPjfliTVPe3pyLE0BExcFtfne3CxRHsYtYfcZj26tT4Y3BxeFyyFx0ItMrixlE2GsG2DzlL2NxqDypFtZ2m6wy6Drgtgi5NtlTk1ZD0FGzzKtAxaGrgshtUyiF0gJteld2htixUHtY243h0GDtWR6ztVxTG0eTxVEtWZe4KxUFsYtjhc3wuDrgjtDsntFCsbdGzJLuFFsTTxxEuFztQ0OHzrRsnxYEzp9zplzFJznZthlRe284GznQtBxYHzn9znizFJznZtRdfl2jzlQsZxWFzjby4NtRy4D1XHtkVdjy5KsCrgtgifUlTk2U2JtZTVrgzLN4JtRjli2Vtkfk5stPj2E5nzOQtExbErgtDxbEzTs3atdsAzXM0uGsBzFWtPzGLtPfWP2I3EzNa5ytWrgsZ2i1n3rguEziZ2azCGxdMz4NuFrisErisH2t2dtcjsHtTR2ftX2ptZ4WsGxSFyJDtcrisSrislxMErgxdK0RGzrH5MtS5JzqF0gIsbxpJxXF28tjT2Ntg0DGrnxTG2itR6m4ntiTVPe3nyKE2m31xcFtfne4uxRHsYtYfcZj26tT4W3BxeFyxFx0ItMrixlE2GsG2DzjK2MxpDyoFtZ2ltejy5DrgtZUz4GzpKshtUyRF0OJteld2ZtixUHtY2w3bzyDtWR6ftVxTG0NTxVEtWZeZxUGsYtjh6uuDDrgjtDsntFCsbd4132uFFsTTxpEuFzVQ0HEzXQsmxUDzZO5ZuAtkzX9zXOy4FzZZtgRi2KtkP11StAxZFrg189zlkzHKzqNuFrisErisHtdf263dsHtTx7D3asGxOFsG5XtcrisSrislxMErgxZG0DGzmHtk5ItcVy6F0SIsbxlFxTFzjTtUR2ytPjfliTVP12ExYD2m3txcFtfne50xRHsYtYfcZj26tT4O3BxeFytFx0ItMrixlE2GsG2DzbG2IxlDykFtZ2htejy1Drg0sDzwFzhKshtU1tH0GHteld2ZtixUHtY2w3XzuDtWR6PtVxTG0JTxVEtWZey3DxUEsYtjh6iuDDrgjtDsntFCsbd4x32uFFsTTxpEuFzVQ0DEzXQsmxUDzZOtkVuAtkzX9zXOy4FzZZtTflekiuBtP2I3HzhQsbxcGrg2I2czp9zpgzLNzxNuFrisErisHtdxXD3gsHxmGtZ3gsGxRFsG5htcrisSrislxME0QHxcD0NGztI5Stc0DG0cOxoDxWFzvTtUR21tPj2mtiTVPe3lyJE2m3zxcFtfneVixRHsYtYfcZj26tT4U3BxeFywFx0ItMrixlE2GsG2DzhJ2LxoDynFtZ2ktejy4D0AD06Dz4FzpM2UyUF0QKtVuAtkxSHtYZU2ezzDtWR6dtVxTG0OTxVEtWZeZxUGsYtjh6tuDDrgjtDsntFCsbdGy4FuFFsTTxrEuFzZQ0KEzZQsmxUDzZ9zZgy4FzZZ43te23te2NtkPzjU68xeHrgzt9ztkzPPz3NuFrisErisHtdf3C3isHtfi2HtijsGxOFsG2qtcrisSrislxMErgxZG0LGzpI5Qtcz8G0aIsnxlFxTFzoTtUR2ytPjfliTVzMFxYD2m3txcFtfn4jtixRHsYtYfcZj26tT4O3BxeFytFx0ItMrixlE2GsG2DzbG2IxlDykFtZ2htejy1DrgtZUzwGzhKshtU1yH0GHteld2ZtixUHtY2w3MzuDtWR6PtVxTG0JTxVEtWZeZxUGsYtjh6iuDDrgjtDsntFCsbd4x32uFFsTTxpEuFzVQ5W6HtljzZQtDxWFzdOtkVuAtkzb9zbOy8HzfZ3RtV2htVUP2FzjQsbxaGrgsZzVIznHtUxWD3v3Dzr9zrOzLLzvZ5OtViP3vzsQtFxXDrgshtUzpOteld2etizn9znOzDIzkZ2K3Vtfle5JzlPscxYHzm9zmizFJzna4VtZm33uBtPRkkVdgjzvRxgHrgsZxgGz39z3jzVQ0CNuFrisErisHtdfxlDtjsHtgi2ItlTkjsGxQFsGtRdcrisSrislxMErgxbI0VGzxHtk5atcVzIF0kItAxnHxVFzwTtU4KtRPjfliTVPe3jyIE2m3xxcFtfne4sxRHsYtYf47tj26tT4S3BxeFyvFx0ItMrixlE2GsG2DzfI2KxnDymFtZ2jtejy3DrgtZUz0GzlKshtU18H0KHteld2ZtixUHtY2w3ZzwDtWR6XtVxTG0LTxVEtWZeZxUGsYtjh6ouDDrgjtDsntFCsbd4z32uFFsTTxpEuFzVQtT6ItVXfiuBtPzeSsbxbHrgzn9znkzLMzwZteR2KzpQsmxUDziO3MuAtkzg9zgOy4FzZZ2ItiTYRek0BEzgPslxbHrgshtUznOteld2itizp9zpOzLMzwZtg3WtT5SzqPtAxVEzk9zkiy8GzeZ3QtVRkVUP2FzjQsbxaGrgsZtkzoOtUxWD3u3Dzq9zqOzLLzvNuFrisErisHtdf273esHtl3htijsGxNFsGtRdcrisSrislxMErgxYF0FGznHtk5KtczkG0UItFxkExSFzhT0ZDtRPjfli6ztPe3d0sFtV3rxcFtfne26xRHsYtYfcZj26tT4M3BxeFysFx0ItMrixlE2GsG2DzZF2HxkDyjF14Dtejy0Drg62zuGzfKshtU1qH0EHteld2ZtixUHtY2w3WztDtWR6LtVxTG0ITxVEtWZeZxUGsYtjh6fuDDrgjtDsntFCsbd4w32uFFsTTxpEuFzVQ2FtXePlgP1NDtVzfQtDxcDrgtFgrgscxcDzpOxuEzn9znOzLNzxh2lzvYsZtkztS313Dzx9zxWzLDzvZ3BtijkPeR2QzvQsexaErgxaEzvO4tuA0EIzr9zrHzHLzrZ4ZzqXskxZDzp9zplzFKzoZtVdRZx0DzkOsdxVEzg9zggy6GzcZtSZikYPUR35zhQsaxaE2qxaDzmOxSLzm9zmHzHLzrZtX2I3GtiznQsfxWFzjO3JuAtkzj9zjOy8HzfZtT24uBtP3rzgQsbxXDrg2XzhOteld2ez1Izj9zjHzDIzkZtWlccPeR2PzmQsexZDrgxZEzoOtkVuAtkzm9zmOzFKzoNuFrisErisHtdf263d2H6dtTYRekjsGxRFsG2ttcrisSrislxMErgxcJ0HGzqI5Mtc0AG0WIslxoIxWFzsTtUR21tPjfl2ntVzVFxYD2m3zxcFtfne3CxRHsYtYfcZj26tT4U3BxeFywFx0ItMrixlE2GsG2DzhJ2LxoDynFtZ2Utejy4DrgtZUz2GznKshtU15H0MHteld2ZtixUHtY2w3azxDtWR6btVxTG0MTxVEtWZeZxUGsYtjh6ruDDrgjtDsntFCsbd4032uFFsTTxpEuFzVQ0GEzXQsmxUDzZOtkVuAtkzX9zXOy4FzZZtT22uBtP1zSsbxXDrg149zhkzDIzkNuFriuFsEritR2psb2y6W2DrisSrisHHrg6ftgcfi5RztDxOD0KLtjxTJ2Btl4PtijsGtX2Z33tisExWJ23xPD2XtjkRk2hxWHysDxIF3ItRjl3ayIMtTfl34sAx1H4isBxyNznF2CzULzRQyGEtgifUlTk3CteR2b0hHyvOzeJ6K5ttd2ptkf4ktcPj2E2f0CDxeQtPxfLtPfWP2ItcyZH6463zTHsc5o3jtVksmy2DrisSri3AtdfPV5Vtd2OtiT2R2mtT4B2k5isetZ3HtA2qtYxjDsH2i2mtcjsHytMxTFsG3GtcriuF"
}
}
**2. Define the aggregation with coarser level-of-detail**
export const step_2_sql = `WITH aql__1 AS (
SELECT
products.name AS "products->name"
SUM(order_items.amount) AS "sum_order_items->amount"
FROM
order_items
LEFT JOIN
products
ON order_items.product_id = products.id
GROUP BY 1
),\n
aql__2 AS (
SELECT
SUM(order_items.amount) AS "sum_order_items->amount"
)\n
SELECT
aql__1."products->name" as "products->name",
aql__1."sum_order_items->amount" as "total_sales",
aql__2."sum_order_items->amount" as "_total_sales_of_all"
FROM
aql__1
CROSS JOIN aql__2
`
Total sales of all order items is also needed to be used as denominator for the percent of total. This metric can be definied by using [`of_all()`](/reference/aql/of_all) metric function.
```aml
Model order_items {
...
measure total_sales {...}
measure _total_sales_of_all {
label: 'Total Sales of All'
type: 'number'
definition: @aql order_items.total_sales | of_all(order_items) ;;
}
}
```
export const step_3_sql = `WITH
\naql__1 AS (
SELECT
SUM(amount) AS total_sales
FROM order_items
),
\naql__2 AS (
SELECT
product,
SUM(amount) AS value
FROM order_items
)
\nSELECT
aql__2.product,
aql__2.value,
(aql__2.value / aql__1.total) AS percent_per_total
FROM aql__2
CROSS JOIN aql__1
`
**3. Calculate the percent of total**
Finally, the Percent of Total is determined by dividing the `total_sales` by `_total_sales_of_all`. Since `_total_sales_of_all` remains constant, the percent of total will adjust according to `total_sales` metric and its corresponding dimension
```aml
Model order_items {
...
measure total_sales {...}
measure _total_sales_of_all {...}
measure percent_of_total {
label: "Percent of Total"
type: 'number'
definition: @aql (order_items.total_sales*1.0) / order_items._total_sales_of_all ;;
}
}
```
_To maintain the decimal format for the percent of total metric, multiplying by `1.0` explicitly casts the left side to decimal._
export const step_3 = {
"fields": [
"Product",
"Total Sales",
"Percent of Total"
],
"records": [
[
"Chocolate 2",
"2187",
"0.0498%"
],
[
"Samsung Tablet 2",
"101313",
"2.307%"
],
[
"H&M T-shirt",
"25194",
"0.5737%"
],
[
"Face Treatment",
"4743",
"0.1080%"
],
[
"Adidas Shoes",
"38901",
"0.8858%"
],
[
"Xbox",
"102797",
"2.3407%"
],
[
"Jordan Shoes",
"18760",
"0.4272%"
],
[
"Bunk Bed",
"13893",
"0.3163%"
],
[
"AFC Biscuits",
"2350",
"0.0535%"
],
[
"Sony Headphone 1",
"10665",
"0.2428%"
],
[
"Sofa 1",
"50334",
"1.1461%"
],
[
"Body Scrub",
"211359",
"4.8126%"
],
[
"Flour 1",
"800",
"0.0182%"
],
[
"Converse Shoes",
"40575",
"0.9239%"
],
[
"Outdoor Sofa",
"24999",
"0.5692%"
],
[
"Bathroom Mirror",
"3960",
"0.0902%"
],
[
"Face Moisturizers",
"4576",
"0.1042%"
],
[
"Wii",
"24090",
"0.5485%"
],
[
"Cookies 2",
"6049",
"0.1377%"
],
[
"Face Cream",
"17365",
"0.3954%"
],
[
"Nokia C1",
"87307",
"1.9879%"
],
[
"High Cabinet",
"8432",
"0.1919%"
],
[
"Lacoste T-shirt",
"21994",
"0.5008%"
],
[
"Body Cleanser",
"161143",
"3.6692%"
],
[
"Reebox Shoes",
"21231",
"0.4834%"
],
[
"Ticwatch 1",
"74019",
"1.6854%"
],
[
"KitKat 1",
"1037",
"0.2361%"
],
[
"Umbrella",
"29855",
"0.6798%"
],
[
"Guess Jeans",
"13881",
"0.3161%"
],
[
"10 Deep T-shirt",
"9398",
"0.2140%"
],
[
"Gucci T-shirt",
"7826",
"0.1782%"
],
[
"Nike Shoes",
"62529",
"1.4238%"
],
[
"Chips 2. Dried Fruit",
"5435",
"0.1238%"
],
[
"Plant Pot",
"21953",
"0.5000%"
],
[
"Sony Smart TV",
"70961",
"1.6158%"
],
[
"Wrangler Jeans",
"8562",
"0.1950%"
],
[
"Baking Soda 1",
"4536",
"0.1033%"
],
[
"iPhone X",
"72053",
"1.6406%"
],
[
"TV Stand",
"53058",
"1.2081%"
],
[
"Dining Chair 1",
"31056",
"0.7071%"
],
[
"Xbox 360",
"90142",
"2.0525%"
],
[
"Playstation 4",
"39596",
"0.9016%"
],
[
"Single Bed",
"2340",
"0.0533%"
],
[
"Oil 2",
"26525",
"0.6040%"
],
[
"Apple Watch 1",
"197375",
"4.4942%"
],
[
"Samsung Smart TV",
"72128",
"1.6423%"
],
[
"iPhone 7",
"77086",
"1.7552%"
],
[
"Kindle Fire 2",
"69054",
"1.5723%"
],
[
"Kindle Fire 1",
"203271",
"4.6284%"
],
[
"BlackBerry Key 1",
"146508",
"3.3359%"
],
[
"Samsung Bluetooth Earphone",
"84791",
"1.9307%"
],
[
"Essence Lotions",
"18550",
"0.4224%"
],
[
"Flour 2",
"12157",
"0.2768%"
],
[
"Oil 1",
"17379",
"0.3957%"
],
[
"Face Mask",
"11484",
"0.2615%"
],
[
"LG Smart TV",
"67865",
"1.5453%"
],
[
"Samsung Galaxy 2",
"127246",
"2.8974%"
],
[
"Bedroom Mirror",
"23857",
"0.5432%"
],
[
"Fireplace",
"73942",
"1.6836%"
],
[
"iPad 2",
"108816",
"2.4777%"
],
[
"Chips 1",
"7673",
"0.1747%"
],
[
"Panasonic Smart TV",
"129750",
"2.9544%"
],
[
"Outdoor Lighting",
"11568",
"0.2634%"
],
[
"Bathroom Slippers",
"7764",
"0.1768%"
],
[
"Samsung Tablet 1",
"50690",
"1.1542%"
],
[
"Shower curtains",
"8347",
"0.1901%"
],
[
"Towels",
"4363",
"0.0993%"
],
[
"Playstation 3",
"32682",
"0.7442%"
],
[
"Sony Headphone 2",
"35732",
"0.8136%"
],
[
"Samsung Galaxy 1",
"45974",
"1.0468%"
],
[
"Smart Lighting",
"14635",
"0.3332%"
]
],
"metadata": {
"aql": "explore {\n dimensions {\n products.name\n }\n measures {\n order_items.total_sales,\n order_items.percent_of_total\n }\n}",
"executed_sql": "WITH \"aql__t3\" AS (\n SELECT\n (\"order_items\".\"quantity\" * \"products\".\"price\") AS \"amount\",\n \"order_items\".\"product_id\" AS \"product_id\"\n FROM\n \"demo\".\"order_items\" \"order_items\"\n LEFT JOIN \"demo\".\"products\" \"products\" ON \"order_items\".\"product_id\" = \"products\".\"id\"\n), \"aql__t1\" AS (\n SELECT\n \"products\".\"name\" AS \"products->name\",\n SUM(\"order_items\".\"amount\") AS \"sum_order_items->amount\"\n FROM\n \"aql__t3\" \"order_items\"\n LEFT JOIN \"demo\".\"products\" \"products\" ON \"order_items\".\"product_id\" = \"products\".\"id\"\n GROUP BY\n 1\n), \"aql__t4\" AS (\n SELECT\n SUM(\"order_items\".\"amount\") AS \"sum_order_items->amount\"\n FROM\n \"aql__t3\" \"order_items\"\n)\nSELECT\n \"aql__t1\".\"products->name\" AS \"products->name\",\n \"aql__t1\".\"sum_order_items->amount\" AS \"order_items->total_sales\",\n ((\"aql__t1\".\"sum_order_items->amount\" * 1.0) / \"aql__t4\".\"sum_order_items->amount\") AS \"order_items->percent_of_total\"\nFROM\n \"aql__t1\"\n CROSS JOIN \"aql__t4\"\n",
"url": "https://play.amql.org/#code/uDritRdcsbtfekV2DrisS2OsH2FriisE2HtiVcRkZ2YtjYZgjsGxkDxYDtCxULrgtUVdfPTfl23tiZVjxKD2RtPxUGtZ2kxRFxbEuBxZF3ptMe3utkuBtgVsSrgntdRe2Wtk2o3LtVrnxXEtWifd2Xse20tcU3ktWxrG2B3M23tcxuDyRGxpFxrDxlExVEyfLxbFuFyjFtfyKbzvIyNRyFKxDD2C0tStg4mtUlTk5btZU0qGtfi2Hti2Otk2M2UxdHxcD0t7yULzlPyiK0wmzyIyKPyCK0tXzIF0rWxMH0p90pZygI0nmzsG0lwtlj0kR2PxVE0e9zTWyaH0YmzfF0XwtTZ2QtV0YK0mGxVD5D0X9zSUyZH0WmzeG0XarisErisHtmynD0WDsGtRdcrisSritT0gDtkrgsZlktPGsdtCDshnmrgsV2ysKGIrnxrEtU6DtR4ZtkjsHxpDxQNxJHyHHscxOG1nGtV3Vtdd3mtTVziGxqEtPjflxRDtPe2v1gExnEzYDxeE0BFtj2UtLz4G0IGsExNH21tekixQKtfi2sti6Wtk3BxSNxNItgi3PtlT4ixPHtl4X2bxLEtNytGti3r4Q1DDtjYZgy0KxWMsA0IFy5JxKD2RtPxUGzdFxbHuBxZFsErg2KtlVsBywHyKSzUI42tUyHG0HLxdIxcDyKg0rGyIVxMHyGh1VFyFO2PxVEx8j1FHyAIyOGxVD6Qx8Jx7EtN2FuFrisErisH2j2ntcjsHrisSrixOLz6LsGxTFsGtRdcxjDslxMErgxeLrguDyPE3ftgVsSrgntkRScVrnxRE4atS2oxSDsnxqKxYF2OtjTi4vz3ExeDxTG5ItRPjfliTVPe3pyLE0BExcFtfne3CxRHsYtYfcZj26tT4Y3BxeFyyFx0ItMrixlE2GsG2DzlL2NxqDypFtZ2m6wy6Drgtgi5NtlTk1ZD0FGzzKtAxaGrgshtUyiF0gJteld2htixUHtY243h0GDtWR6ztVxTG0eTxVEtWZe4KxUFsYtjhc3wuDrgjtDsntFCsbdGzJLuFFsTTxxEuFztQ0OHzrRsnxYEzp9zplzFJznZthlRe284GznQtBxYHzn9znizFJznZtRdfl2jzlQsZxWFzjby4NtRy4D1XHtkVdjy5KsCrgtgifUlTk2U2JtZTVrgzLN4JtRjli2Vtkfk5stPj2E5nzOQtExbErgtDxbEzTs3atdsAzXM0uGsBzFWtPzGLtPfWP2I3EzNa5ytWrgsZ2i1n401MuEyPDyxEzxMzqX3ptiTV5LzlEyFFznPtMritAxhGzkEzzF2Sznr14NzoLsCJGI4XsH0KN16Uz5LuFrisErisHtdf2wtcjsHtTR2ytX28tZ2psGxSFsGtRdcrisSrislxMErgxdK0VGzvH3ftS3czuF0kHrnsbxpJxXF28tjT2Ntg0HGrnxTG2itR4Xtf606OtPe3nyKE2m31xcFtfne5ExRHsYtYfcZj26tT4W3BxeFyxFx0ItMrixlE2GsG2DzjK2MxpDyoFtZ2ltejy5DrgtZUz4GzpKshtUyRF0OJteld2ZtixUHtY2w3bzyDtW62tjVxTG0NTxVEtWZeZxUGsYtjh6uuDDrgjtDsntFCsbd4132uFFsTTxpEuFzVQ0HEzXQsmxUDzZO5ZuAtkzX9zXOy4FzZZtgRi2KtkP11StAxZFrg189zlkzHKzqNuFrisErisHtdf263dsHtTx7D3asGxOFsG5XtcrisSrislxMErgxZG0DGzmHtk5ItcVy6F0SIsbxlFxTFzjTtUR2ytPjfliTVP12ExYD2m3txcFtfne50xRHsYtYfcZj26tT4O3BxeFytFx0ItMrixlE2GsG2DzbG2IxlDykFtZ2htejy1Drg0sDzwFzhKshtU1tH0GHteld2ZtixUHtY2w3XzuDtWR6PtVxTG0JTxVEtWZey3DxUEsYtjh6iuDDrgjtDsntFCsbd4x32uFFsTTxpEuFzVQ0DEzXQsmxUDzZOtkVuAtkzX9zXOy4FzZZtTflekiuBtP2I3HzhQsbxcGrg2I2czp9zpgzLNzxNuFrisErisHtdxXD3gsHxmGtZ3gsGxRFsG5htcrisSrislxME0QHxcD0NGztI5Stc0DG0cOxoDxWFzvTtUR21tPj2mtiTVPe3lyJE2m3zxcFtfneVixRHsYtYfcZj26tT4U3BxeFywFx0ItMrixlE2GsG2DzhJ2LxoDynFtZ2ktejy4D0AD06Dz4FzpM2UyUF0QKtVuAtkxSHtYZU2ezzDtWR6dtVxTG0OTxVEtWZeZxUGsYtjh6tuDDrgjtDsntFCsbdGy4FuFFsTTxrEuFzZQ0KEzZQsmxUDzZ9zZgy4FzZZ43te23te2NtkPzjU68xeHrgzt9ztkzPPz3NuFrisErisHtdf3C3isHtfi2HtijsGxOFsG2qtcrisSrislxMErgxZG0LGzpI5Qtcz8G0aIsnxlFxTFzoTtUR2ytPjfliTVzMFxYD2m3txcFtfn4jtixRHsYtYfcZj26tT4O3BxeFytFx0ItMrixlE2GsG2DzbG2IxlDykFtZ2htejy1DrgtZUzwGzhKshtU1yH0GHteld2ZtixUHtY2w3MzuDtWR6PtVxTG0JTxVEtWZeZxUGsYtjh6iuDDrgjtDsntFCsbd4x32uFFsTTxpEuFzVQ5W6HtljzZQtDxWFzdOtkVuAtkzb9zbOy8HzfZ3RtV2htVUP2FzjQsbxaGrgsZzVIznHtUxWD3v3Dzr9zrOzLLzvZ5OtViP3vzsQtFxXDrgshtUzpOteld2etizn9znOzDIzkZ2K3Vtfle5JzlPscxYHzm9zmizFJzna4VtZm33uBtPRkkVdgjzvRxgHrgsZxgGz39z3jzVQ0CNuFrisErisHtdfxlDtjsHtgi2ItlTkjsGxQFsGtRdcrisSrislxMErgxbI0VGzxHtk5atcVzIF0kItAxnHxVFzwTtU4KtRPjfliTVPe3jyIE2m3xxcFtfne4sxRHsYtYf47tj26tT4S3BxeFyvFx0ItMrixlE2GsG2DzfI2KxnDymFtZ2jtejy3DrgtZUz0GzlKshtU18H0KHteld2ZtixUHtY2w3ZzwDtWR6XtVxTG0LTxVEtWZeZxUGsYtjh6ouDDrgjtDsntFCsbd4z32uFFsTTxpEuFzVQtT6ItVXfiuBtPzeSsbxbHrgzn9znkzLMzwZteR2KzpQsmxUDziO3MuAtkzg9zgOy4FzZZ2ItiTYRek0BEzgPslxbHrgshtUznOteld2itizp9zpOzLMzwZtg3WtT5SzqPtAxVEzk9zkiy8GzeZ3QtVRkVUP2FzjQsbxaGrgsZtkzoOtUxWD3u3Dzq9zqOzLLzvNuFrisErisHtdf273esHtl3htijsGxNFsGtRdcrisSrislxMErgxYF0FGznHtk5KtczkG0UItFxkExSFzhT0ZDtRPjfli6ztPe3d0sFtV3rxcFtfne26xRHsYtYfcZj26tT4M3BxeFysFx0ItMrixlE2GsG2DzZF2HxkDyjF14Dtejy0Drg62zuGzfKshtU1qH0EHteld2ZtixUHtY2w3WztDtWR6LtVxTG0ITxVEtWZeZxUGsYtjh6fuDDrgjtDsntFCsbd4w32uFFsTTxpEuFzVQ2FtXePlgP1NDtVzfQtDxcDrgtFgrgscxcDzpOxuEzn9znOzLNzxh2lzvYsZtkztS313Dzx9zxWzLDzvZ3BtijkPeR2QzvQsexaErgxaEzvO4tuA0EIzr9zrHzHLzrZ4ZzqXskxZDzp9zplzFKzoZtVdRZx0DzkOsdxVEzg9zggy6GzcZtSZikYPUR35zhQsaxaE2qxaDzmOxSLzm9zmHzHLzrZtX2I3GtiznQsfxWFzjO3JuAtkzj9zjOy8HzfZtT24uBtP3rzgQsbxXDrg2XzhOteld2ez1Izj9zjHzDIzkZtWlccPeR2PzmQsexZDrgxZEzoOtkVuAtkzm9zmOzFKzoNuFrisErisHtdf263d2H6dtTYRekjsGxRFsG2ttcrisSrislxMErgxcJ0HGzqI5Mtc0AG0WIslxoIxWFzsTtUR21tPjfl2ntVzVFxYD2m3zxcFtfne3CxRHsYtYfcZj26tT4U3BxeFywFx0ItMrixlE2GsG2DzhJ2LxoDynFtZ2Utejy4DrgtZUz2GznKshtU15H0MHteld2ZtixUHtY2w3azxDtWR6btVxTG0MTxVEtWZeZxUGsYtjh6ruDDrgjtDsntFCsbd4032uFFsTTxpEuFzVQ0GEzXQsmxUDzZOtkVuAtkzX9zXOy4FzZZtT22uBtP1zSsbxXDrg149zhkzDIzkNuFriuFsEritR2psb2y6W2DrisSrisHHrg6ftgcfi5RztDxOD0KLtjxTJ2Btl4PtijsGtX2Z33tisExWJ23xPD2XtjkRk2hxWHysDxIF3ItRjl3ayIMtTfl34sAx1H4isBxyNznF2CzULzRQyGEtgifUlTk3CteR2b0hHyvOzeJ6K5ttd2ptkf4ktcPj2E2f0CDxeQtg2KtT1WDtPfWPxpFzJEyYF6362zSHsc5n3itVksmy1DrisSri28tdfPV5Utd2Ox7DxmDtT4A2k3GsetZ3ltA2qtYxjDsH2i2mtcjsHysMxTFsG3GtcriuF"
}
}
**4. Percent of total in one line**
The entire steps described above can also be straightforwardly condensed into one single-line expression.
```aml
Model order_items {
...
measure percent_of_total {
label: "Percent of Total"
type: 'number'
definition: @aql (sum(order_items.amount) * 1.0) / (sum(order_items.amount) | of_all(order_items));;
}
}
```
---
## Period Comparison
:::info Alternative
We also support [native GUI-based time period comparison](/docs/reporting/period-comparison.md).
:::
## Introduction
Period Comparison is an analytic technique to compare metrics from different time periods, such as comparing the revenue between this year and last year. In this guide, we will walk you through detailed steps on how to calculate period comparison metrics in different approaches for different use cases
## Setup
```aml
# e_commerce.dataset.aml
Dataset e_commerce {
models: [orders, countries]
relationships: [
relationship(orders.country_id > countries.id, true),
]
}
# orders.model.aml
...
# countries.model.aml
...
```
## Method 1: Relative period
You may consider writing measure that relatively changes based on the present period or the conditioned period. This is useful when you want to compare a specific KPI from one period to another one relatively. In AQL, we can employ [relative_period()](/reference/aql/relative_period) to perform that result
```aml
# orders.model.aml
...
measure count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
measure count_orders_previous_year {
label: "Count Orders Previous Year"
type: "number"
// highlight-next-line
definition: @aql orders.count_orders | relative_period(orders.created_at, interval(-1 year)) ;;
}
measure percent_change_previous_year {
label: "Percent Change Previous Year"
type: "number"
definition: @aql safe_divide(
(orders.count_orders - orders.count_orders_previous_year)*1.0,
orders.count_orders_previous_year
);;
}
```
*Example 1*
*Example 2*
## Method 2: Fixed period vs fixed period
:::tip
Note that the filter condition applied at the exploration layer is combined with the measure condition using `AND` logic. Hence the measure condition may yield an inaccurate result if it doesn’t cover the entire exploration condition’s time range. E.g. `count_orders_2023` will return no data if there is an exploration condition `orders.created_at matches @(2024)` applied
If you would like to use filter condition together with a defined period, check out the [*method 3*](#method-3) and [*method 4*](#method-4) below. It will provide a clearer understanding of how the filter condition and defined period interact
:::
In case you prefer to maintain a fixed period within the measure definition. You can apply [where()](/reference/aql/where) along with a time operator to filter the specific period from which you want to derive the measure
```aml
# orders.model.aml
...
measure count_orders_2023 {
label: "Count Orders in 2023"
type: "number"
// highlight-next-line
definition: @aql orders.count_orders | where(orders.created_at matches @2023) ;;
}
measure count_orders_this_year {
label: "Count Orders this Year"
type: "number"
// highlight-next-line
definition: @aql orders.count_orders | where(orders.created_at matches @(this year)) ;;
}
measure percent_change_2023_this_year {
label: "Percent Change 2022 and This Year"
type: "number"
definition: @aql
safe_divide(
(orders.count_orders_this_year - orders.count_orders_2023)*1.0,
orders.count_orders_2023
);;
}
```
*Example 1*
*Example 2*
## Method 3: Fixed period and fixed granularity {#method-3}
If you need to compare a specific period to a series of consecutive periods, you can employ the [of_all()](/reference/aql/of_all) function to fulfill this use case.
```aml
# orders.model.aml
...
measure count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
measure count_orders_fixed_holiday_season_2022 {
label: "Count Orders Holiday Season 2022"
type: "number"
definition: @aql orders.count_orders
| where(orders.created_at matches @2022-12)
| of_all(orders)
;;
}
measure percent_difference_fixed_holiday_season_2022 {
label: "Percent Diffence to Fixed Holiday 2022"
type: "number"
definition: @aql safe_divide(
orders.count_orders*1.0,
orders.count_orders_fixed_holiday_season_2022
) ;;
}
```
_Example_
## Method 4: Fixed period and dynamic granularity {#method-4}
Similar to Method 2, this approach enables explicit filtering on the desired period. However, it also provides the flexibility to separate its context from the filter conditions which is applied at the exploration layer. We’ll employ [exact_period()](/reference/aql/exact_period) to perform this use case. In this example, we’ll compare a metric at an arbitrary period to Holiday Season period
```aml
# orders.model.aml
...
measure count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
measure count_orders_holiday_2022 {
label: "Count Orders Holiday Season 2022"
type: "number"
// highlight-next-line
definition: @aql orders.count_orders | exact_period(orders.created_at, @2022-12-01 - 2023-12-31);;
}
measure percent_change_to_holiday_2022 {
label: "Percent Difference to Holiday Season 2022"
type: "number"
definition: @aql safe_divide(orders.count_orders*1.0, orders.count_orders_holiday_season_2022) ;;
}
```
*Example 1*
*Example 2*
*Example 3*
The `exact_period()` function ensures that the condition applied to the `created_at()` field at the exploration level does not affect this measure. However, if `created_at()` is used as a dimension and the exploration is set to a smaller granularity, the comparison will be between two periods at that specific level of detail.
For example, if `created_at()` is set to a daily granularity and the condition is applied as `last month`, the exploration will provide a day-by-day comparison between the previous month and the holiday season of 2022.
## Method 5: Compound annual growth rate (CAGR)
For multi-year growth, a single year-over-year number can be misleading. CAGR smooths growth across N years into one figure, useful for "average annual growth across the period" headlines.
Build it from two `relative_period()` metrics (current revenue and revenue N years ago). Then apply the CAGR formula:
```aml
metric revenue_2_years_ago {
label: 'Revenue 2 Years Ago'
type: 'number'
definition: @aql
revenue
| relative_period(orders.created_at, interval(-2 years))
;;
}
metric revenue_2y_cagr {
label: '2-Year CAGR'
type: 'number'
definition: @aql
(pow(revenue / revenue_2_years_ago, 1.0 / 2) - 1) * 100
;;
}
```
The formula is `(end / start)^(1/n) - 1`. For an N-year CAGR, replace the `2`s with N. Multiply by 100 to express as a percent.
This composes with any dimension. Drop `revenue_2y_cagr` alongside `merchants.name` to see each merchant's 2-year growth rate, or `categories.name` for category-level CAGR.
---
## Ranking
## Introduction
The requirement to rank items in a report is a common business requirement. For example, you need to know the top-performing products in a certain aspect so you can double-down on them, or knowing the worst performing items so you can investigate for further problems.
In this guide, we will walk you through some examples of how to use the `rank()` function in Holistics to answer the following questions:
- What are the products with the highest sales in the whole platform?
- What are the products rankings within each category?
- Compare products’ ranks within the category and across all categories
Overall, ranking falls into two categories: Dynamic Ranking and Static Ranking.
- **Dynamic Ranking:** Affected by filters and dimensions, and is a metric in Holistics.
- **Static Ranking:** Not affected by filters and dimensions. A Static Ranking field is a dimension.
The first two questions can be answered by a ranking metric (dynamic ranking), while the third question need a ranking dimension (static ranking)
## Example 1: Ranks all products by total sales
### Setup
In this example, the `e_commerce` dataset will contain the following models: `order_items` , `products` .
```aml
// order_items.model.aml
Model order_items.model.aml {
...
dimension order_id {...}
dimension product_id {...}
dimension quantity {...}
}
// products.model.aml
Model products {
...
dimension id {...}
dimension price {...}
dimension category_id {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [order_items, products]
relationships: [
relationship(order_items.product_id > products.id, true)
]
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price);;
}
metric ranking {
label: 'Ranking'
type: 'number'
definition: @aql rank(order: total_sales | desc());;
}
}
```
### High-level flow
1. **Create a Revenue metric:** In the `e_commerce` dataset, we create a simple `revenue` metric that calculate order value by summing over `order_items.quantity` multiplied by `products.price`.
2. **Create a Ranking metric:** Use the `rank()` function to take `revenue` as in put and generate ranking numbers in descending order (larger revenue value comes first)
3. **Visualize:** Include the Ranking metric with Product Name in the visualization to answer your questions
### Implementation
#### 1. Create a Revenue metric
In the definition file of the `e_commerce` dataset, we can easily create a **Revenue** metric that combines fields from two different models (`order_items` and `products`). This will be the basis for our ranking:
```aml
Dataset e_commerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql coalesce(
order_items | sum(order_items.quantity * products.price),
0
) ;;
}
}
```
In cases where a dimension (like products, or users) have no order records, the corresponding revenue will be 0 instead of NULL. This way we do not need to worry about different ways that different databases handle NULL when ranking.
#### 2. Create a Ranking metric
We continue to create the **Ranking** metric that use the **Revenue** metric as the ordering field. The syntax `revenue | desc()` is necessary if we want the revenue values are ranked in descending order.
```aml
Dataset e_commerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
metric ranking {
label: 'Ranking'
type: 'number'
definition: @aql rank(order: revenue | desc());;
}
}
```
#### 3. Visualize
You can combine the Ranking metric with any dimensions to calculate it ranking basing on revenue. For example, here we visualize the ranking of Products:
The ranking metric can also be used to filter. For example, we can choose to display the top 3 products by revenue:
## Example 2: Ranking within a category
Sometimes knowing what sells the most across the whole platform is not insightful enough - we want to know the top products within each category. In this example, we will explore how to answer this question.
### Setup
The setup will be quite similar to Example 1. The only difference is that we will add the `categories` model to the dataset:
```aml
// order_items.model.aml
Model order_items.model.aml {
...
dimension order_id {...}
dimension product_id {...}
dimension quantity {...}
}
// products.model.aml
Model products {
...
dimension id {...}
dimension price {...}
dimension category_id {...}
}
// merchants.model.aml
Model merchants {
...
dimension id {...}
dimension name {...}
}
// categories.model.aml
Model categories {
...
dimension id {...}
dimension name {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [order_items, products, merchants]
relationships: [
relationship(order_items.product_id > products.id, true)
relationship(products.merchant_id > merchants.id, true)
relationship(products.category_id > categories.id, true)
]
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price);;
}
metric ranking_by_cate {
label: 'Ranking by Cate'
type: 'number'
definition: @aql rank(order: revenue | desc(), partition: categories.name);;
}
}
```
### High-level flow
1. **Create a Ranking by Category metric:** In this new ranking metric, we will include **Category Name** as the partition field
2. **Visualization:** For this ranking metric with partition to work, we will need to include the partition field (**Category Name**) field in the visualization.
### Implementation
#### 1. Create a Ranking by Category metric
When defining the ranking metric, we reuse the Revenue metric that we have defined. We will pass the `categories.name` field into the `partition` argument of the `rank()` function to specify that the ranking should be calculated within a category:
```aml
Dataset e_commerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
metric ranking_by_cate {
label: 'Ranking by Category'
type: 'number'
definition: @aql rank(order: revenue | desc(), partition: categories.name);;
}
}
```
#### 2. Visualization
When specifying the `categories.name` field as the partition field, we must include it in the visualization for the ranking to work correctly.
Below is the result, where we only display products with rank from 1 to 3 within a category.
## Example 3: Static Product Ranking
The two ranking examples we have done so far fall under the category of **“Dynamic Ranking”.** The ranking is dynamic in the sense that the rank values are affected by new filters and dimensions that we include in the visualization.
For example, without any filter, the product **Chips 1** has rank 5, but when applying the filter **Category Name is “Snack”**, the ranking is re-calculated, and Chips 1 has rank 1.
What if we want to display only Products in the Snacks category, but still retain their overall rankings? In this example, we will explore how to produce a **Static Ranking** that is unaffected by filters.
### Setup
The only difference with the other examples is how we create the ranking field. In previous examples, the ranking fields are **metrics,** but in static ranking, the field will be a **dimension**.
```aml
// order_items.model.aml
Model order_items.model.aml {
...
dimension order_id {...}
dimension product_id {...}
dimension quantity {...}
}
// products.model.aml
Model products {
...
dimension id {...}
dimension price {...}
dimension category_id {...}
}
// merchants.model.aml
Model merchants {
...
dimension id {...}
dimension name {...}
}
// categories.model.aml
Model categories {
...
dimension id {...}
dimension name {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [order_items, products, merchants]
relationships: [
relationship(order_items.product_id > products.id, true)
relationship(products.merchant_id > merchants.id, true)
relationship(products.category_id > categories.id, true)
]
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price);;
}
dimension product_ranking_dim {
label: 'Product Ranking (Dimension)'
type: 'number'
model: products
definition: @aql rank(order: revenue | dimensionalize(products.id) | desc());;
}
}
```
### High-level Flow
1. **Create a Ranking dimension:** Instead of creating a ranking as a metric, now we will define it as a dimension.
2. **Visualization:** For this ranking metric with partition to work, we will need to include the partition field (**Category Name**) field in the visualization.
### Implementation
#### 1. Create a Ranking Dimension
We will reuse the `revenue` metric in the dataset as the basis for the ranking. Then, we define the `product_ranking_dim` dimension in the dataset file, but specify that this dimension belongs to the `products` model.
```aml
Dataset e_commerce {
...
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price);;
}
dimension product_ranking_dim {
label: 'Product Ranking (Dimension)'
type: 'number'
model: products
definition: @aql rank(order: revenue | dimensionalize(products.id) | desc());;
}
}
```
For more details about this process of **dimensionalize a metric**, please refer to our [dimensionalize](/reference/aql/dimensionalize) doc.
#### 2. Visualization
To compare the result created by the ranking metric and dimension, we will use the following fields: Product ID, Product Name, Ranking, Product Ranking (Dimension). Without any filtering, the two return the same ranking values:
Now if we add **Category Name is “Audio Gadget”** filter, we can see that the **Ranking metric**’s values are re-calculated, while the **Product Ranking dimension**’s values are maintained. There are gaps between the values because products of other categories have been removed from the visualization.
## Example 4: Performance tiers with `ntile()`
Sometimes you don't want exact ranks. You want to bucket items into tiers (top 25%, bottom quartile, etc.). [`ntile()`](/reference/aql/ntile) splits an ordered set into N equal-sized buckets.
The setup is the same as Example 1. We define a dimension on `products` that assigns each product to a quartile based on revenue:
```aml
Dataset e_commerce {
...
metric revenue { ... }
dimension product_performance_tier {
model: products
label: 'Product Performance Tier'
type: 'text'
definition: @aql
case(
when: ntile(4, order: revenue | desc()) = 1, then: 'Top 25%',
when: ntile(4, order: revenue | desc()) = 2, then: '25-50%',
when: ntile(4, order: revenue | desc()) = 3, then: '50-75%',
else: 'Bottom 25%'
)
| dimensionalize(products.id)
;;
}
}
```
`ntile(4, order: revenue | desc())` ranks products by revenue and divides them into 4 buckets. Bucket 1 contains the top 25%. We wrap it in `dimensionalize(products.id)` so the tier is fixed to each product and reusable as a regular dimension.
Use it to analyze the distribution of products across tiers:
```aml
explore {
dimensions {
products.product_performance_tier
}
measures {
product_count: count(products.id),
total_revenue: revenue,
avg_revenue_per_product: revenue / count(products.id)
}
}
```
Swap `4` for `10` to get deciles, or use `ntile(100, ...)` for percentiles.
---
## Semi-Additive Calculation
## Introduction
Any calculations can be either Additive, Semi-additive, or Non-additive.
- **Additive Metrics**: are metrics that can be aggregated (or summed) across all the Dimensions that are use with it. For example, the Total Order Items of a Year is the SUM of Total Order Items of each individual Months in that Year.
- **Non-additive Metrics**: are metrics that cannot be aggregated (or summed) across any of the dimensions. For example, the Distinct Count of Products over a year is not the Sum of the Distinct Count Products of Each Month in that Year.
- **Semi-additive Metrics**: are metrics can be aggregated across some dimensions, but not all dimensions. A typical example that can be referred in the Example below
## Example: Bank Balance
For our example, think of a model that tracks bank balances. If you add up everyone's balance, that's the total amount for all users.
But when considering time, it's not as straightforward. Say we're looking at a quarter: the balance isn't just adding up each month's amount. Instead, we go by the last recorded balance for that quarter.
So, adding up balances depends on what we focus on: time or other factors. This is why we call it 'semi-additive': it can be both additive and non-additive, depending on the situation.
As you can see, a simple SUM to aggregate balances returns the wrong result both at the quarter and at the year levels:
The total at the quarter level cannot be computed by summing the individual months. Instead, the formula must only consider the last value.
## Possible approaches
One of the solution is to search for the Last Date of the Balance Date and compare it with the Date used in the Dimension
```aml
metric sum_bank_balance {
label: 'Sum Bank Balance'
type: 'number'
definition: @aql sum(balances.bank_balance)
| where(dim_dates.date == max(balances.record_date)) ;;
}
```
You will get the below result, the Total Balance of Quarter is the Total Balance of the last Month in that Quarter, instead of summing all the Months in that Quarter
However, you might notice that the Total Balance of all Customers looks wrong. It doesn’t take the sum balance of all Customers.
To investigate this, you might want to use the actual balance date of each customer.
The last balance dates for Hehr Elliott, Molleur Florance, and Francis Kub are January 26th, January 20th, and January 28th. When AQL looks at the latest date for the final two customers, it shows January 26th and January 20th. For Francis Kub, the latest date is January 28th.
If no specific customer is selected, the latest date becomes the last date for all customers, which is January 28th. But there's no data for Hehr Elliott and Molleur Florance on this day.
So, based on what your company needs, you might want the last balance day for each customer or the last balance day for all of them.
### Calculate the Last Balance Day for All Customers
If you want to calculate the Last day for the All Customers, you may want to use the `of_all()` function in your calculation.
```aml
metric sum_bank_balance_any_customers {
label: 'Sum Bank Balance Any Customers'
type: 'number'
definition: @aql
sum(balances.bank_balance) | where(dim_dates.date == (
max(balances.record_date) | of_all(balances.user_name)
)) ;;
}
```
You will get the result like below
### Calculate Last Balance Day for each Customer.
The other Solution is to calculate the Last Balance Day for each individual customer, and then sum the number together.
You can leverage the function `unique()` to calculate the **Last Balance day** by each Customer
```aml
metric sum_bank_balance_each_customers {
label: 'Sum Bank Balance Each Customers'
type: 'number'
definition: @aql
unique(balances.user_name)
| select(
user_name: balances.user_name,
sum_balance: (sum(balances.bank_balance) | where(dim_dates.date == max(balances.record_date)))
)
| sum(sum_balance)
;;
}
```
You will get the result like below
---
## Level of Detail Patterns
For the concept, vocabulary, and decision table, see **[Learn AQL → Level of Detail](/as-code/aql/learn/level-of-detail)**. This page walks through four real-world patterns end to end.
The simplest LoD case (percent of total with `of_all()`) has its own dedicated page: **[Percent of Total](/as-code/aql/cookbook/aql-percent-of-total)**. The patterns below cover the trickier shapes.
## Nested aggregation vs `dimensionalize()`
These two come up most often, and on the surface they can produce similar results. They behave differently under filters:
- Use **`dimensionalize()`** when you want the aggregation usable as a *group-able dimension* (bins, histograms, cohort buckets).
- Use **nested aggregation** for everything else. It's the more intuitive default.
A concrete difference: with a filter like `orders.created_at matches 'last month'`, "Max of (AOV measure)" computes AOV from only last month's orders, while "Max of (AOV dimension via `dimensionalize`)" computes AOV across all-time orders for users who placed an order last month.
## Use case 1: higher LoD. nested aggregation
**Question:** "What is the maximum AOV (average order value) of a customer in each country?"
Two-step calculation:
1. AOV per customer (high Calc LoD: needs `users.id`).
2. Max of those AOVs grouped by country.
The customer-level AOV measure:
```aml
Model users {
measure aov {
definition: @aql
sum(order_items, order_items.quantity * products.price) * 1.0
/ count_distinct(orders.id)
;;
}
}
```
The dataset-level metric that nests it:
```aml
Dataset ecommerce {
metric max_user_aov {
definition: @aql users | group(users.id) | select(user_aov: users.aov) | max() ;;
}
}
```
When the report groups by country, AQL first computes AOV per user, then takes the max within each country group.
## Use case 2: fixed LoD. `dimensionalize()`
**Question:** "Show countries colored by the highest customer AOV available there."
Here AOV behaves like a customer-level *property*, not a measure that recomputes per report. Define it as a dimension fixed to `users.id`:
```aml
Model users {
dimension aov_dim {
type: 'number'
definition: @aql
sum(order_items, order_items.quantity * products.price) * 1.0
/ count_distinct(orders.id)
| dimensionalize(users.id)
;;
}
}
```
And in the dataset:
```aml
Dataset ecommerce {
metric max_user_aov {
definition: @aql max(users.aov_dim) ;;
}
}
```
Same shape as use case 1, but the AOV is locked to the user grain. See the [comparison note above](#nested-aggregation-vs-dimensionalize) for when this matters.
## Use case 3: customer segmentation flag. `dimensionalize()` for filtering
**Question:** "Identify VIP customers (lifetime revenue > $10k and 5+ orders), then break down orders by VIP vs. non-VIP."
This is `dimensionalize()` applied to a *boolean expression*. The aggregation collapses to a per-customer flag that other reports can filter and group by.
```aml
dimension is_vip_customer {
model: users
label: 'Is VIP Customer'
type: 'truefalse'
definition: @aql
(sum_revenue > 10000 and count(orders.id) >= 5)
| dimensionalize(users.id)
;;
}
```
Now any report can filter on `users.is_vip_customer is true` or group by it. The flag is fixed to the user grain regardless of what the surrounding report does. That's the point of `dimensionalize()`.
Same shape as use case 2, but the output is a category, not a number.
## Use case 4: per-level percentages. `is_at_level()`
**Question:** In a pivot with `Continent → Country → City`, compute percent-of-parent at every level.
```aml
case(
when: is_at_level(cities.name),
then: sum(sales.amount) / (sum(sales.amount) | of_all(cities.name)),
when: is_at_level(countries.name),
then: sum(sales.amount) / (sum(sales.amount) | of_all(countries.name)),
when: is_at_level(countries.continent),
then: sum(sales.amount) / (sum(sales.amount) | of_all(countries.continent)),
else: 1
)
```
[`is_at_level()`](/reference/aql/miscellaneous-functions#is_at_level) detects which dimension is active and picks the right denominator. The `else: 1` covers the Grand Total row.
## See also
- [`of_all()`](/reference/aql/of_all): function reference
- [`dimensionalize()`](/reference/aql/dimensionalize): function reference
- [Nested aggregation guide](/as-code/aql/cookbook/aql-nested-aggregation)
- [Percent of total guide](/as-code/aql/cookbook/aql-percent-of-total)
---
## Metrics by Example
The Cookbook landing page. Three ways to find what you need:
- **[By question](#by-question)**: "I want to do X" → the page that shows you how.
- **[By metric shape](#by-metric-shape)**: what shape is the metric, and which function handles it.
- **[Snippet gallery](#snippet-gallery)**: copy-paste AML for the most common patterns.
## Shared e-commerce schema
Most examples on this page and across the Cookbook use this schema:
**Models:** `users`, `orders`, `order_items`, `products`, `categories`, `merchants`, `cities`, `countries`.
**Key relationships:**
- `order_items` → `orders` (many:1) via `order_id`
- `order_items` → `products` (many:1) via `product_id`
- `orders` → `users` (many:1) via `user_id`
- `users` → `cities` → `countries` (geographic chain)
- `products` → `categories` (many:1)
- `products` → `merchants` (many:1)
## By question
When you know the *question* but not the *AQL feature*:
| I want to… | Go to |
|---|---|
| Slice a metric by a dimension and add filters | [Learn → Filtering](/as-code/aql/learn/filtering), [Grouping](/as-code/aql/learn/grouping) |
| Define a reusable metric or AQL dimension | [Learn → Defining a Metric](/as-code/aql/learn/defining-a-metric) |
| Build a metric that spans multiple models | [Learn → Cross-Model Queries](/as-code/aql/learn/cross-model) |
| Compute a ratio (e.g. completion rate) | [Learn → Defining a Metric](/as-code/aql/learn/defining-a-metric#metrics-compose), [Cookbook → Percent of Total](/as-code/aql/cookbook/aql-percent-of-total) |
| Segment customers with a boolean flag (VIP, churned, etc.) | [Cookbook → LoD Patterns](/as-code/aql/cookbook/level-of-detail#use-case-3-customer-segmentation-flag--dimensionalize-for-filtering) |
| Bucket records into tiers (deciles, quartiles) | [Cookbook → Ranking](/as-code/aql/cookbook/aql-rank#example-4-performance-tiers-with-ntile) |
| Compute percent of total | [Learn → Level of Detail](/as-code/aql/learn/level-of-detail), [Cookbook → Percent of Total](/as-code/aql/cookbook/aql-percent-of-total) |
| Aggregate already-aggregated data (avg of monthly totals, max of per-user metric) | [Learn → Nested Aggregation](/as-code/aql/learn/nested-aggregation), [Cookbook → Nested Aggregation](/as-code/aql/cookbook/aql-nested-aggregation) |
| Rank items, with or without partitions | [Cookbook → Ranking](/as-code/aql/cookbook/aql-rank) |
| Year-over-year, quarter-over-quarter comparison | [Learn → Time Comparisons](/as-code/aql/learn/time-comparisons), [Cookbook → Period Comparison](/as-code/aql/cookbook/aql-period-comparison) |
| Smooth a time series with a moving average | [Cookbook → Moving Average](/as-code/aql/cookbook/aql-moving-average) |
| Compound annual growth rate (CAGR) | [Cookbook → CAGR](/as-code/aql/cookbook/aql-period-comparison#method-5-compound-annual-growth-rate-cagr) |
| Analyze customer order frequency / cohort distributions | [Cookbook → Customer Order Frequency](/as-code/aql/cookbook/aql-customer-order-frequency) |
## By metric shape
Most metric questions resolve once you answer two questions:
1. **One aggregation, or several?** A single `sum`/`count`/`avg` is simple aggregation. Anything that aggregates an already-aggregated value (avg of monthly totals, percent of total, semi-additive sums) is multi-aggregation.
2. **What's the relationship between aggregations or filters?** Conditions, time periods, subgroups, accumulation, or nesting each map to a different AQL function.
### Single-aggregation metrics
| Shape | Example | Where to go |
|---|---|---|
| Multiple tables/models | `order_items \| sum(order_items.quantity * products.price)` | [Cross-Model Queries](/as-code/aql/learn/cross-model) |
| Filtered by condition | `avg(order_items.quantity) \| where(order_items.created_at matches @(last 3 months))` | [where()](/reference/aql/where) + [logical operators](/reference/aql/operator#logical-operator) |
| Running / accumulated | `orders.total \| running_total(orders.created_at \| year())` | [Cumulative Metrics](/as-code/aql/cookbook/aql-cumulative-metrics), [window_sum](/reference/aql/window_sum) |
| Different time period | `orders.count \| relative_period(orders.created_at, interval(-1 year))` | [Period Comparison](/as-code/aql/cookbook/aql-period-comparison) |
### Multi-aggregation metrics
| Shape | Example | Where to go |
|---|---|---|
| Scalar combination of metrics | `sum(products.price) / count(products.id)` | Just write `metric_a / metric_b` |
| Aggregations linked by a condition | `sum(balances.bank_balance) \| where(dim_dates.date == max(balances.record_date))` | [Semi-Additive Calculation](/as-code/aql/cookbook/aql-semi-additive-calculation) |
| Calculated across different subgroups | `(order_items.total_sales*1.0) / (order_items.total_sales \| of_all(order_items))` | [Level of Detail Patterns](/as-code/aql/cookbook/level-of-detail), [Percent of Total](/as-code/aql/cookbook/aql-percent-of-total) |
| Nested aggregations | `users \| group(month(users.sign_up_at)) \| select(count(users.id)) \| avg()` | [Nested Aggregation](/as-code/aql/cookbook/aql-nested-aggregation) |
## Snippet gallery
Copy-paste-able definitions for the most common patterns.
### Simple aggregation
VIDEO
Simple aggregation works by taking a table and an expression to evaluate over each row of that table.
```js title="Total Price of all Products after Discount"
// `products` source table expression can be omitted
products | sum(products.price * products.discount)
```
```js title="Average Ordered Quantity of Order Items"
avg(order_items.quantity)
```
### Conditional aggregation
```js title="Average Ordered Quantity of Order Items over the Last 3 Months"
avg(order_items.quantity)
| where(order_items.created_at matches @(last 3 months))
```
### Aggregation with scalar functions
```js title="Average Price of Product"
sum(products.price) / count(products.id)
```
```js title="Total Actual Product Price"
products | sum(products.price * products.discount)
```
### Cross-model aggregation
```js title="Gross Merchandise Value (GMV)"
order_items | select(order_items.quantity * products.price) | sum()
// or more succinctly
order_items | sum(order_items.quantity * products.price)
```
```js title="GMV of refunded orders"
order_items
| sum(order_items.quantity * products.price)
| where(orders.status != 'refunded')
```
See [Cross-Model Queries](/as-code/aql/learn/cross-model) for the full walkthrough.
### Cumulative metrics
```js title="Cumulative Sum of GMV"
// orders.value = order_items | sum(order_items.quantity * products.price)
sum(orders.value) | running_total()
```
```js title="Running Percentage of Asia GMV over global GMV"
(sum(orders.value) | running_total() | where(continents.name == 'Asia'))
/ (sum(orders.value) | running_total())
```
See [Cumulative Metrics](/as-code/aql/cookbook/aql-cumulative-metrics) for the full walkthrough.
### Window functions
```js title="Running Sum of Count over Order Status"
window_sum(count(orders.id), order: orders.status)
```
### Multiple aggregation levels
```js title="Average GMV per Customer"
orders | group(users.id) | select(sum(orders.value)) | avg()
```
See [Nested Aggregation](/as-code/aql/cookbook/aql-nested-aggregation).
### Custom relationships
```js title="Sum of revenue using custom relationships"
sum(order_items.revenue)
| with_relationships(
order_items.order_id > orders.id,
order_items.country_id > countries.id,
)
```
See [`with_relationships`](/reference/aql/with_relationships).
### Semi-additive metrics
```js title="Sum Bank Balance Any Customers"
sum(balances.bank_balance)
| where(dim_dates.date == (
max(balances.record_date) | of_all(balances.user_name)
))
```
See [Semi-Additive Calculation](/as-code/aql/cookbook/aql-semi-additive-calculation).
---
In Holistics, data exploration is typically done through the UI, which generates AQL queries in the [`explore { }`](/reference/aql/explore-expression) format. The examples here show AML metric definitions. In practice, you define metrics once in AML and the UI generates the explore syntax for you.
---
## Enabling AQL for Legacy Users
:::info
This guide is only for users who signed up **before April 19, 2024**. Users who signed up on or after this date have AQL enabled by default and can skip this guide.
:::
## Enabling AQL
For legacy users, enabling AQL for your dataset involves a simple process of adding version flagging at the dataset level. You just need to incorporate a version flag in your dataset definition as: `__engine__: 'aql'`.
This will activate AQL within your specified dataset while other datasets continue to operate using the prior engine.
```aml
Dataset aql_dataset {
__engine__: 'aql' //turn this Dataset to using AQL Engine
data_source_name: 'your_datasource_name'
models: [ ]
relationships: [ ]
}
```
:::warning Important Note
We suggest creating a duplicate of the dataset you wish to apply AQL to for backward compatibility. You can then add version flags to this cloned dataset. Once you've tested AQL on the clone, you are free to enable AQL for your main dataset.
If you find that the new AQL engine impacts your existing reports, you can conveniently remove the version flags from the concerned dataset.
:::
---
## Learn AQL from SQL background
This guide helps SQL practitioners understand AQL's metric-centric paradigm and migrate their analytics workflows to leverage Holistics' semantic layer capabilities.
## Target Audience
- Data analysts comfortable with SQL looking to adopt semantic modeling
- Analytics engineers migrating from SQL-based pipelines
- Teams evaluating AQL for scalable analytics infrastructure
## What is AQL?
AQL (Analytics Query Language) is Holistics' semantic modeling language that sits between your data warehouse and business users. Instead of writing SQL queries repeatedly, you define metrics, dimensions, and relationships once, creating a reusable semantic layer.
**Traditional SQL workflow:**
```
Data Warehouse → SQL Queries → Reports
```
**AQL workflow:**
```
Data Warehouse → Semantic Layer (AQL) → Self-Service Analytics
```
## Fundamental Shift in Thinking
When transitioning from SQL to AQL, the most significant change is how you approach building analytics. While SQL encourages thinking in terms of data transformations, AQL promotes thinking in terms of business metrics.
**SQL starts with raw tables and transforms them step by step:**
1. Transform the raw orders data to calculate daily sales
2. Transform those daily totals to get monthly aggregates
3. Transform the monthly data to calculate what users asked for - growth rates
Each query builds a pipeline where output feeds into the next transformation.
**AQL starts with what users need and works backwards:**
1. What metrics do users want? -> Month-over-month growth rate
2. What components can be used to build it? -> Current month sales and previous month sales
3. What existing metrics can we reuse? -> sales metric
4. What data is needed? → The orders table with amount field
This approach creates a library of reusable metrics that combine flexibly for different analyses, rather than single-purpose SQL queries that solve one specific problem.
### Practical Example: Period-over-Period Growth
**SQL's Sequential Build-up:**
```sql
-- Layer 1: Daily aggregation
WITH daily_sales AS (
SELECT DATE(order_date) as date, SUM(amount) as sales
FROM orders
GROUP BY DATE(order_date)
),
-- Layer 2: Roll up to monthly
monthly_sales AS (
SELECT
DATE_TRUNC('month', date) as month,
SUM(sales) as total_sales
FROM daily_sales
GROUP BY DATE_TRUNC('month', date)
),
-- Layer 3: Compute growth metrics
monthly_growth AS (
SELECT
month,
total_sales,
LAG(total_sales) OVER (ORDER BY month) as prev_month_sales,
(total_sales - LAG(total_sales) OVER (ORDER BY month)) /
LAG(total_sales) OVER (ORDER BY month) * 100 as growth_rate
FROM monthly_sales
)
SELECT * FROM monthly_growth;
```
**AQL's Goal-First Definition:**
```aml
Dataset ecommerce {
// Business need: growth rate
metric monthly_sales_growth {
definition: @aql
safe_divide((sales - sales_last_month) * 100, sales_last_month)
;;
}
metric sales {
definition: @aql sum(orders.amount) ;;
}
metric sales_last_month {
definition: @aql
sales
| relative_period(orders.created_at, interval(-1 month))
;;
}
}
```
Notice how AQL starts with the desired outcome and works backward to identify dependencies, while SQL builds forward through transformation layers.
## From SQL to AQL Components
### SELECT -> explore
The equivalent of a SELECT statement in Holistics is an [explore expression](/reference/aql/explore-expression). Unlike SQL, you don't write explore blocks directly - Holistics generates them through the UI when users build reports.
Instead of writing queries, you focus on building reusable components:
- **Models** - Business entities (users, orders, products)
- **Relationships** - How models connect (orders -> users)
- **Metrics** - Reusable aggregations (revenue, user count)
- **Dimensions** - Attributes and calculated fields
These components combine dynamically based on user selections in the UI.
**SQL Query:**
```sql
SELECT
DATE_TRUNC('month', created_at) as month,
status,
COUNT(*) as order_count,
SUM(amount) as revenue
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY 1, 2
```
**AQL Components (you define):**
```aml
Model orders {
dimension created_at { type: 'datetime' }
dimension status { type: 'text' }
dimension amount { type: 'number' }
}
Dataset ecommerce {
metric order_count {
definition: @aql count(orders.id) ;;
}
metric revenue {
definition: @aql sum(orders.amount) ;;
}
}
```
**Explore (generated by UI):**
```aml
explore {
dimensions {
orders.created_at | month(),
orders.status
}
measures {
order_count,
revenue
}
filters {
orders.created_at >= @2024
}
}
```
### FROM -> source table
When aggregating data in SQL, you must choose a FROM table. This table determines the granularity of your data (assuming you don't cause fan-out with joins):
```sql
SELECT SUM(amount) FROM orders -- Aggregates at order level
```
In AQL, all aggregation functions take the first parameter as the source table:
```aml
// Explicit: aggregate amount from orders table
sum(orders, orders.amount)
```
**Most of the time, you don't need to specify the source table** because AQL can auto-infer it from the expression inside the aggregate:
```aml
sum(orders.amount) // Auto-inferred: clearly from orders table
```
This works because when you only reference fields from a single model, AQL knows to use that model as the source table.
**Only specify the source table when:**
1. **Cross-model calculations** - When combining fields from multiple models:
```aml
// Must specify order_items as base table for revenue calculation
sum(order_items, order_items.quantity * products.price)
```
2. **Complex aggregations** - When nesting aggregations:
```aml
// Average number of items per order
orders
| group(orders.id)
| select(sum(order_items.quantity))
| avg()
```
:::tip Knowledge Checkpoint
The pipe operator (`|`) chains operations on expressions. In this example, `orders | group(...)` takes `orders` and applies a group on `orders.id` to it. Learn more about [pipe operators in AQL](/reference/aql/operator#pipe).
:::
When choosing the source table, always choose the one that makes logical sense for your aggregation. For example, `count(order_items, orders.id)` would count order items instead of orders.
### JOIN ON -> Relationships
SQL requires you to join tables explicitly in every query where you need data from multiple tables.
In Holistics, you don't define joins - you define the relationships between tables once in the dataset. For example:
- one user has many orders
- one order has many order_items
- many order_items have one product
From these relationship definitions, AQL will choose the appropriate join in the generated SQL.
```sql
-- SQL: Explicit joins in every query
SELECT
o.id,
u.name,
SUM(oi.quantity * p.price) as revenue
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
GROUP BY o.id, u.name
```
```aml
-- AQL: Define relationships once
Dataset ecommerce {
relationships [
// many orders to one user
relationship(orders.user_id > users.id, true),
// many items to one order
relationship(order_items.order_id > orders.id, true),
// many items to one product
relationship(order_items.product_id > products.id, true),
]
}
```
### Dimensions vs Columns
In SQL, you work with physical columns and create calculated fields in your SELECT statements. In AQL, dimensions represent both physical columns and reusable calculated fields that become part of your semantic model.
#### Table column -> dimension
In Holistics, you can define calculated dimensions alongside physical columns in any model. These dimensions act like real columns and can reference other columns in their calculations.
```aml
Model users {
// Physical columns from database
dimension id { type: 'number' }
dimension created_at { type: 'datetime' }
dimension first_name { type: 'text' }
dimension last_name { type: 'text' }
// Calculated dimensions
dimension full_name {
label: 'Full Name'
type: 'text'
definition: @aql concat(users.first_name, ' ', users.last_name) ;;
}
dimension account_age_days {
label: 'Account Age (Days)'
type: 'number'
definition: @aql date_diff('day', users.created_at, @now) ;;
}
dimension user_segment {
label: 'User Segment'
type: 'text'
definition: @aql
case(
when: users.account_age_days < 30, then: 'New User',
when: users.account_age_days < 90, then: 'Active User',
else: 'Established User'
) ;;
}
}
```
You can also use window functions in dimensions.
**Example: Customer order history analysis**
```aml
Model orders {
dimension previous_order_date {
label: 'Previous Order Date'
type: 'datetime'
definition: @aql previous(orders.created_at | day(), order: orders.created_at | day()) ;;
}
}
```
Window functions operate on the complete underlying table.
The mental model for dimensions looks like this:
```sql
WITH holistics_model AS (
SELECT
*, -- all underlying real columns
dimension_expression AS dimension_name,
window_function_expression AS window_dimension_name
FROM underlying_table
)
-- Use holistics_model as if it's the underlying table
SELECT * FROM holistics_model WHERE ...
```
#### Cross-model dimension
Dimensions can directly access fields from related models when there's a many-to-one or one-to-one relationship.
If many order items have one product, then it's guaranteed that one order item row only corresponds to one product. This means rows in `order_items` can access columns in `products` as if they are in the same table.
```aml
Model order_items {
dimension quantity { type: 'number' }
dimension line_item_revenue {
label: 'Line Item Revenue'
type: 'number'
definition: @aql order_items.quantity * products.price ;;
}
dimension product_category {
label: 'Product Category'
type: 'text'
definition: @aql categories.name ;;
}
}
```
This works because:
- `order_items` -> `products` (many:1)
- `products` -> `categories` (many:1)
So each order_item can "see" its product's price and category name directly.
#### Dimensionalized Metrics
One of AQL's most powerful features is turning metrics into dimensions using `dimensionalize()`. This enables analyses that would require complex subqueries in SQL:
**SQL - Customer lifetime value as a dimension:**
```sql
WITH customer_ltv AS (
SELECT
user_id,
SUM(amount) as lifetime_value
FROM orders
GROUP BY user_id
)
SELECT
CASE
WHEN c.lifetime_value >= 10000 THEN 'VIP'
WHEN c.lifetime_value >= 5000 THEN 'High Value'
WHEN c.lifetime_value >= 1000 THEN 'Regular'
ELSE 'New'
END as customer_tier,
COUNT(DISTINCT o.user_id) as customer_count,
SUM(o.amount) as revenue
FROM orders o
JOIN customer_ltv c ON o.user_id = c.user_id
WHERE o.created_at >= '2024-01-01'
GROUP BY 1
```
**AQL - Same logic as reusable dimensions:**
```aml
// Define lifetime value as a dimension
dimension customer_lifetime_value {
model: users
type: 'number'
definition: @aql sum(orders.amount) | dimensionalize(users.id) ;;
}
// Create customer tier based on LTV
dimension customer_tier {
model: users
type: 'text'
definition: @aql
case(
when: customer_lifetime_value >= 10000, then: 'VIP',
when: customer_lifetime_value >= 5000, then: 'High Value',
when: customer_lifetime_value >= 1000, then: 'Regular',
else: 'New'
)
;;
}
// Now use as normal dimension in explore
explore {
dimensions { users.customer_tier }
measures {
customer_count: count(users.id),
revenue: sum(orders.amount)
}
filters { orders.created_at >= @2024 }
}
```
The dimensionalized metric calculates each user's lifetime value once and makes it available as a dimension for grouping, filtering, or further calculations.
### Metric
In AQL, metrics are building blocks that encapsulate business logic - from simple sums to complex calculations involving multiple tables, filters, and time intelligence.
#### Basic Aggregation
In SQL, aggregations are always tied to a specific query context. Aggregations in SQL always operate in a specific context (of a GROUP BY or a WHERE).
In AQL, **a metric is a first-class citizen** - a reusable custom aggregation function. A basic metric is built like this:
```aml
metric sales {
definition: @aql sum(orders.amount) ;;
}
```
Notice that **you don't specify a specific GROUP BY** or anything. This is one pitfall of people coming from SQL - they think that the grouping should be part of the metric. But in AQL, you should only introduce grouping if absolutely necessary. **The grouping should be chosen dynamically** as part of the explore for end-users, not in the metric definition itself.
#### Multi-model metrics
When creating metrics that aggregate across multiple models, you must specify the base table for aggregation. This is especially important for cross-model calculations.
Since metrics in AQL are standalone, they must bring their own FROM table. When working with fields from a single model, AQL can infer the source table. But when combining fields from multiple models, AQL can't infer which table should be the base for aggregation - you must specify the correct FROM table explicitly.
The first parameter tells AQL which table to use as the "FROM" for aggregation:
```aml
// Basic revenue metric aggregating at order_items level
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
```
#### Composed metrics
Metrics can be built from other metrics, creating a composable library of business logic:
```aml
// Base metrics
metric total_revenue {
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
metric total_orders {
definition: @aql count(orders.id) ;;
}
// Composed metric
metric revenue_per_order {
label: 'Revenue per Order'
type: 'number'
definition: @aql safe_divide(total_revenue, total_orders) ;;
}
```
#### SUM(CASE WHEN) -> Metrics with where()
SQL uses CASE WHEN for conditional aggregation:
```sql
SELECT
SUM(amount) as total_revenue,
SUM(CASE WHEN status = 'delivered' THEN amount END) as delivered_revenue,
SUM(CASE WHEN created_at >= '2024-01-01' THEN amount END) as ytd_revenue
FROM orders
```
AQL uses the `where()` function to filter any metric:
```aml
// Base metric
metric revenue {
definition: @aql sum(orders.amount) ;;
}
// Filtered variations
metric delivered_revenue {
definition: @aql revenue | where(orders.status == 'delivered') ;;
}
metric ytd_revenue {
definition: @aql revenue | where(orders.created_at >= @2024) ;;
}
// Or apply filters dynamically in explore
explore {
measures {
total: revenue,
delivered: revenue | where(orders.status == 'delivered'),
cancelled: revenue | where(orders.status == 'cancelled')
}
}
```
**Important difference:**
Unlike SQL, AQL returns `null` (not 0) when no data matches the filter:
```aml
// Returns null if no electronics products exist
electronics_revenue: revenue | where(categories.name == 'Electronics')
```
#### GROUP BY -> Level of Detail
Metrics in AQL can override the default grouping behavior using `of_all()` to create Level of Detail calculations:
```aml
// Calculate percent of total - ignoring product dimension
metric product_percent_of_total {
label: 'Product % of Total Revenue'
type: 'number'
definition: @aql
safe_divide(revenue * 100.0, revenue | of_all(products))
;;
}
// Calculate percent within category
metric percent_of_category {
label: '% of Category Revenue'
type: 'number'
definition: @aql
safe_divide(revenue * 100.0, revenue | of_all(products.name))
;;
}
```
The `of_all()` function tells AQL to calculate the metric without grouping by the specified dimensions, enabling calculations like "percent of total" that would require subqueries in SQL.
#### Nested (Multi-Level) Aggregation
AQL supports nested aggregations through the `group()` and `select()` pattern. This is equivalent to SQL's subqueries or CTEs but more composable:
```aml
// Average monthly customer acquisition
metric avg_monthly_new_customers {
label: 'Avg Monthly New Customers'
type: 'number'
definition: @aql
users
| group(users.created_at | month())
| select(monthly_count: count(users.id))
| avg(monthly_count)
;;
}
// Max daily revenue
metric max_daily_revenue {
label: 'Max Daily Revenue'
type: 'number'
definition: @aql
order_items
| group(order_items.created_at | day())
| select(
daily_revenue: sum(order_items, order_items.quantity * products.price)
)
| max(daily_revenue)
;;
}
```
This pattern:
1. Groups data by specific dimensions
2. Calculates metrics for each group
3. Applies a second aggregation on the results
It's more flexible than SQL because the same metric works in any context - by year, by country, etc.
### WITH … (CTEs)
AQL doesn't require CTEs because its composable nature handles complex logic through:
1. **Reusable dimensions and metrics** - Define once, use everywhere
2. **Pipe operators** - Chain operations naturally
3. **Table functions** - `group()`, `select()`, `filter()` replace CTE patterns
**SQL with CTEs:**
```sql
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) as month,
SUM(amount) as sales
FROM orders
GROUP BY 1
),
avg_monthly AS (
SELECT AVG(sales) as avg_sales
FROM monthly_sales
)
SELECT
month,
sales,
sales - (SELECT avg_sales FROM avg_monthly) as variance
FROM monthly_sales;
```
**AQL equivalent:**
```aml
// Define the metrics
metric sales {
definition: @aql sum(orders.amount) ;;
}
metric avg_monthly_sales {
definition: @aql
orders
| group(orders.created_at | month())
| select(sales)
| avg(sales)
;;
}
metric sales_variance {
definition: @aql sales - avg_monthly_sales ;;
}
// Use in exploration
explore {
dimensions {
orders.created_at | month()
}
measures {
sales,
sales_variance
}
}
```
The AQL approach creates reusable components instead of query-specific CTEs.
---
## Cross-model queries
In SQL, every query that touches more than one table starts with a `JOIN`. AQL flips that around: relationships are declared once in your dataset, then any query just references the columns it needs.
:::tip Try it interactively
Practice in the AQL Playground: [Relationships](https://play.amql.org/learn/relationships).
:::
## The basic move
You're querying `order_items`. You want each item's product name (which lives on `products`). Just ask:
```aml
order_items
| select(order_items.id, products.name)
```
No join. AQL sees that `order_items` is related to `products`, generates the right join under the hood, and gives you the result.
## Why this works
A dataset isn't just a list of tables. It's tables plus the **relationships** between them. Each model represents a business entity; each relationship records how those entities connect:
- An `order_items` row belongs to one `orders` row.
- An `orders` row belongs to one `customers` row.
- A `products` row belongs to one `merchants` row.
Because the relationships are known up front, AQL can resolve `products.name` from inside an `order_items` query the same way you'd say it in plain English.
## Multi-hop is fine too
You can reach further as long as each hop is many-to-one:
```aml
order_items
| select(order_items.id, merchants.name)
```
Here AQL walks `order_items → products → merchants`. One item belongs to one product, which belongs to one merchant. So the chain is unambiguous.
## The one rule: many-to-one only
You can reference the "one" side of a relationship freely. The "many" side is different. There isn't a single row to point at. From an `orders` row, `order_items` is a *collection*, not a value:
```aml
// This doesn't work: order_items.value is many rows
orders | select(orders.id, orders.discount * order_items.value)
```
To use the many side, you have to **aggregate it first**:
```aml
orders | select(orders.id, orders.discount * sum(order_items.value))
```
`sum()` collapses the collection back to a single number.
## When relationships are ambiguous
If two models are connected by more than one path, AQL won't guess. Either deactivate the paths you don't want in the dataset, or override per-metric with [`with_relationships()`](/reference/aql/with_relationships):
```aml
sum(order_items.revenue)
| with_relationships(order_items.product_id > products.id)
```
## Next
→ [Defining a metric](/as-code/aql/learn/defining-a-metric): name an aggregation so you can reuse it instead of writing it out every time.
---
## Defining a metric
So far we've been writing aggregations inline. That's the **explore** style: write an AQL expression directly into a report and get an answer. The moment you want to reuse the same aggregation elsewhere, you give it a name and stash it somewhere. That's a **metric**.
The two are a pair. An explore expression is a one-shot AQL query. A metric is the same expression named and saved, ready to be referenced from any report. Same language, two ways to use it.
:::tip Try it interactively
Practice in the AQL Playground: [Reading & Writing Metrics](https://play.amql.org/learn/reading-writing-metrics).
:::
## The simplest possible metric
```aml
Model orders {
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
}
```
Now anywhere you used to write `count(orders.id)`, you can just write `orders.total_orders`. Reports show the same number. Change the formula here once, every report updates.
This is the *"same metric, three reports"* behavior previewed in [What AQL Is For](/as-code/aql/learn/what-aql-is-for). You've now seen the syntax that makes it work. [Metric Context](/as-code/aql/learn/metric-context) (next page) explains how the adaptation actually happens.
## Metrics can include their own filter
You can bake a `where()` into the definition. The filter rides with the metric:
```aml
measure female_user_count {
definition: @aql count(users.id) | where(users.gender == 'Female') ;;
}
```
Anywhere `female_user_count` shows up, it only counts women. No caller has to remember the filter.
## Metrics compose
Once you have a few base metrics, you can build new ones from them:
```aml
metric orders_per_user {
definition: @aql total_orders / total_users ;;
}
```
This is the heart of the language. You build a small vocabulary of trusted metrics, then derive everything else from them.
## Dimensions can be defined in AQL too
The same naming pattern works for dimensions: a "virtual column" derived from an AQL expression. Useful when a piece of row-level logic gets reused across reports.
```aml
dimension is_electronics {
model: products
label: 'Is Electronics'
type: 'truefalse'
definition: @aql categories.name ilike '%Electronics%' ;;
}
```
Now any report can filter or group by `products.is_electronics` instead of repeating the `ilike` expression. Same idea as a metric, just at row grain instead of aggregate grain.
## Two homes for a metric
Holistics uses two words deliberately: a **measure** is defined on a model, a **metric** is defined on a dataset. They're the same kind of thing (a named, reusable AQL expression) at different scopes.
- **In a model** as a `measure {}` when the metric is naturally tied to that one table.
- **In a dataset** as a `metric {}` when the metric spans multiple models (e.g. revenue = sum of `order_items.quantity * products.price`).
For when to use which, see [Where to Define AQL](/as-code/aql/where-to-define-aql).
## Next
→ [Metric context](/as-code/aql/learn/metric-context): why the same metric returns different numbers in different reports, and how to control that.
---
## Filtering
AQL has two ways to apply a filter. They sound the same, but they target different things:
- **[`filter()`](/reference/aql/filter)** filters a **table**. Drop rows that don't match.
- **[`where()`](/reference/aql/where)** filters a **metric**. Restrict what the aggregation sees.
You'll use both. The difference matters once you start defining reusable metrics.
:::tip Try it interactively
Practice in the AQL Playground: [Filters](https://play.amql.org/learn/filters) · [Function: filter](https://play.amql.org/learn/function-filter) · [where() vs filter() vs explore filters](https://play.amql.org/learn/where-filter-filters).
:::
## filter(): narrow down a table
`filter()` is the SQL `WHERE` clause. Give it a table, give it a condition, get back a smaller table.
```aml
orders
| filter(orders.status = 'delivered')
| select(orders.id, orders.total_value)
```
The condition can be any boolean expression valid on a row.
## where(): narrow down a metric
`where()` attaches a filter to an aggregation. It doesn't return a table; it returns a metric that only counts the matching rows.
```aml
// Count of delivered orders
count(orders.id) | where(orders.status = 'delivered')
```
This is what you want inside a metric definition:
```aml
measure delivered_orders {
definition: @aql count(orders.id) | where(orders.status = 'delivered') ;;
}
```
The filter rides along with the metric everywhere it's used.
## When to use which
| You want to… | Reach for |
|---|---|
| Drop rows before aggregating | `filter()` |
| Define a metric that only counts certain rows | `where()` |
| Filter on a derived aggregate (e.g. groups with > 100 users) | `filter()` |
| Filter on a plain dimension or another measure | `where()` works, and is more reusable |
A subtle case: `where()` only accepts certain shapes (dimension-vs-value, dimension-in-list, or dimension-vs-measure). `filter()` accepts any boolean expression but only works on tables. For the full breakdown, see [where vs filter](/reference/aql/where-vs-filter).
## Example: both in the same query
Find countries with at least 100,000 users who placed an order:
```aml
orders
| group(orders.country)
| select(orders.country, users_count: count_distinct(orders.user_id))
| filter(users_count >= 100000)
```
`users_count` is a column that only exists *after* the `select()`, so `where()` can't see it. `filter()` is the right tool.
## Next
→ [Grouping and aggregation](/as-code/aql/learn/grouping): collapse many rows into one number per group.
---
## Grouping and aggregation
Grouping is how you go from "every row" to "one row per category". It works the same way as SQL's `GROUP BY`, but in AQL it's its own pipe-able step.
:::tip Try it interactively
Practice in the AQL Playground: [Aggregate Functions](https://play.amql.org/learn/aggregate-functions) · [Dimensions & Measures](https://play.amql.org/learn/dimensions-measures) · [Function: select & group](https://play.amql.org/learn/function-select-group).
:::
## group() on its own
[`group()`](/reference/aql/group) takes a table and returns one row per distinct value of the dimension you pass:
```aml
order_items | group(order_items.product_id)
```
By itself that's just a list of unique product IDs. Useful, but the real point is to combine it with aggregations.
## Adding an aggregation
Pipe the grouped table into an aggregator like [`count`](/reference/aql/aggregator-functions), [`sum`](/reference/aql/aggregator-functions#sum), or [`avg`](/reference/aql/aggregator-functions). You get one number per group:
```aml
order_items
| group(order_items.product_id)
| select(order_items.product_id, count(order_items.id))
```
That's "number of items sold, per product".
## Grouping across models
Because AQL knows your relationships, you can group by a column on a related model without joining:
```aml
order_items
| group(products.category_name)
| select(products.category_name, sum(order_items.revenue))
```
## A subtle thing about group()
Inside a `group()`, when you reference rows from other models, you get **all rows related to the current group**, not just one. That's what makes the aggregation work: `count(order_items.id)` inside a group sees every order item for that product, not just one.
This is the same trick that powers nested aggregation later. For now, just know: after `group()`, references to related rows fan out into collections that aggregators can collapse.
## The select() must match
Whatever you `select()` after a `group()` has to either be the grouping dimension or an aggregation. This fails:
```aml
order_items
| group(order_items.product_id)
| select(order_items.product_id, countries.name, count(order_items.id))
// ^^^^^^^^^^^^^^ not grouped, not aggregated
```
Same rule as SQL's "column must appear in GROUP BY or be aggregated".
## Next
→ [Cross-model queries](/as-code/aql/learn/cross-model): how AQL reaches into related models without you writing joins.
---
## Level of detail
By default, a metric is calculated at whatever grain the surrounding report is using. Group by country → it aggregates per country. Group by month → it aggregates per month.
**Level of detail (LoD)** is when you want a metric to aggregate at a *different* grain than the surrounding report uses. Three terms worth pinning down:
- **Data LoD**: the grain of the rows you have. `order_items` is high (one row per item); `monthly_sales` is low.
- **Visualization LoD**: the dimensions in the report.
- **Calculation LoD**: the dimensions the metric actually uses. Usually equal to Visualization LoD; LoD functions are how you make them differ.
Three classic cases where Calc LoD ≠ Viz LoD:
1. **Percent of total**: denominator is "across everything", regardless of the current grouping (Calc LoD *lower* than Viz LoD).
2. **Max of per-customer metric by country**: AOV per customer first, then max within country (Calc LoD *higher* than Viz LoD).
3. **Fixed-grain dimension**: a customer's lifetime revenue, used as a filter or category (Calc LoD *fixed*, independent of Viz LoD).
AQL has functions for each. They're all "context modifiers" in the sense of [metric context](/as-code/aql/learn/metric-context).
:::tip Try it interactively
Practice in the AQL Playground: [Level of Detail](https://play.amql.org/learn/level-of-detail) · [Function: of_all](https://play.amql.org/learn/function-of_all) · [Function: dimensionalize](https://play.amql.org/learn/function-dimensionalize).
:::
## of_all(): drop dimensions out of the calculation
`of_all()` tells a metric to ignore any dimension from that model. Percent of total:
```aml
metric country_share {
definition: @aql
count(orders.id) * 1.0
/ (count(orders.id) | of_all(countries))
;;
}
```
The numerator follows the report's grouping (per country). The denominator ignores `countries` and stays the global total.
## exclude(): same idea, different angle
Where `of_all(countries)` says "ignore *only* countries", [`exclude()`](/reference/aql/of_all) says "ignore everything *except* this model's grain". Useful when the report has many dimensions and you want one to win.
## dimensionalize(): make an aggregation usable as a dimension
`dimensionalize()` evaluates an aggregation at a fixed grain, then exposes the result as a dimension. This is how you build things like "VIP customer" flags:
```aml
dimension is_vip_customer {
model: users
type: 'truefalse'
definition: @aql
(sum_revenue > 10000 and count(orders.id) >= 5)
| dimensionalize(users.id)
;;
}
```
Now `users.is_vip_customer` works like any other dimension (filter on it, group by it).
## Choosing between them
Pick by how Calculation LoD relates to Visualization LoD:
| Calc LoD vs Viz LoD | Reach for |
|---|---|
| **Lower** (denominator across everything) | `of_all()` |
| **Higher** (aggregate of an aggregate) | [Nested aggregation](/as-code/aql/learn/nested-aggregation) with `group()` + `select()` |
| **Fixed** (a reusable dimension at a set grain) | `dimensionalize()` |
| **Single grain regardless of report** | `exclude()` |
| **Switches by which dimension is active in a pivot** | [`is_at_level()`](/reference/aql/miscellaneous-functions#is_at_level): see [Cookbook use case 4](/as-code/aql/cookbook/level-of-detail#use-case-4-per-level-percentages--is_at_level) |
For worked end-to-end examples, see [Level of Detail Patterns](/as-code/aql/cookbook/level-of-detail) in the cookbook.
## Next
→ [Nested aggregation](/as-code/aql/learn/nested-aggregation): when you need to aggregate the results of an aggregation.
---
## Metric context
A metric on its own is just an aggregation formula. The actual number depends on the **context** it runs in: which dimensions group the data, what filters apply, what time window is in play.
This is why one metric can power many reports, and why it's worth understanding what's in that context and how to bend it.
:::tip Try it interactively
Practice in the AQL Playground: [Metric vs Explore (1)](https://play.amql.org/learn/metric-vs-explore) · [Metric vs Explore (2)](https://play.amql.org/learn/metric-vs-explore-2).
:::
## The same metric, three contexts
Take a basic revenue metric:
```aml
metric revenue {
definition: @aql sum(order_items.quantity * products.price) ;;
}
```
Drop it into three different reports and you get three different answers:
```aml
// Report 1: grouped by country → revenue per country
explore { dimensions { countries.name } measures { revenue } }
// Report 2: grouped by month → revenue per month
explore { dimensions { orders.created_at | month() } measures { revenue } }
// Report 3: no grouping, with a filter → grand total for 2024
explore { measures { revenue } filters { orders.created_at matches @2024 } }
```
Same definition. The context (dimensions and filters) gives it a different shape each time.
## What's in the context
There are four levers you can pull:
| Lever | What it does | Modified with |
|---|---|---|
| **Condition** | What rows the metric sees | [`where()`](/reference/aql/where) |
| **Relationships** | Which join paths the metric uses | [`with_relationships()`](/reference/aql/with_relationships) |
| **Level of detail** | What grain the metric aggregates at | [`of_all()`](/reference/aql/of_all), [`exclude()`](/reference/aql/of_all), [`dimensionalize()`](/reference/aql/dimensionalize) |
| **Window** | Time-shifted or running calculations | [`relative_period()`](/reference/aql/relative_period), [`running_total()`](/reference/aql/running_total) |
Each of these is a function you pipe a metric through to override one piece of its context.
## Quick demos
**Override the condition**: count only male users, no matter what the report filters on:
```aml
count(users.id) | where(users.gender == 'Male')
```
**Override the relationship**: when two paths exist between models, pick one:
```aml
sum(order_items.revenue)
| with_relationships(
order_items.order_id > orders.id,
order_items.country_id > countries.id
)
```
**Override the level of detail**: total order value across *all* dimensions, ignoring what the report is grouping by:
```aml
sum(products.price * order_items.quantity) | exclude(order_items)
```
**Add a window**: running total of orders by month:
```aml
count(orders.id) | running_total(run: orders.created_at | month())
```
## Why this matters
Most "advanced" AQL is about *modifying context*. Period-over-period comparisons, percent-of-total, cohort retention: they're all "evaluate this metric in a slightly different context than the surrounding report is using". Once you've internalized that, the rest of the language clicks.
## Next
→ [Level of detail](/as-code/aql/learn/level-of-detail): controlling what grain a metric is aggregated at.
---
## Nested aggregation(Learn)
Some questions are aggregations of aggregations:
- "Average **monthly** new customers": first count per month, then average those counts.
- "Best month for revenue": first sum per month, then take the max.
- "Median customer's lifetime value": first sum per customer, then take the median.
In SQL you'd use a subquery or CTE. In AQL, you do it inline with `group() + select()` wrapped in another aggregator.
:::tip Try it interactively
Practice in the AQL Playground: [Nested Aggregations (1)](https://play.amql.org/learn/nested-aggregations) · [Nested Aggregations (2)](https://play.amql.org/learn/nested-aggregations-2) · [Nested Aggregations (3)](https://play.amql.org/learn/nested-aggregations-3).
:::
## The pattern
```aml
(
| group()
| select()
)
```
Read it bottom-up: group by something, calculate per group, then aggregate the groups.
## Example: average monthly new customers
First a regular base metric:
```aml
metric new_customers {
definition: @aql count(users.id) ;;
}
```
Now nest it:
```aml
metric avg_monthly_acquisition {
definition: @aql
users
| group(users.created_at | month())
| select(new_customers)
| average()
;;
}
```
Walking through:
1. `users`: start from the users table.
2. `| group(users.created_at | month())`: bucket by month.
3. `| select(new_customers)`: count customers in each month.
4. `| average()`: average those monthly counts.
Notice the metric has no year-specific logic. Drop it into a report grouped by year and you get the average monthly acquisition *for each year*. Drop it into a report grouped by country and you get average monthly acquisition *per country*. Same metric, adapts to context ([the usual AQL story](/as-code/aql/learn/metric-context)).
## When you need this vs LoD
| Question | Tool |
|---|---|
| Percent of total | [`of_all()`](/as-code/aql/learn/level-of-detail) |
| Aggregation of aggregations | Nested (this page) |
| Use an aggregation as a dimension | [`dimensionalize()`](/as-code/aql/learn/level-of-detail) |
A rough rule: if the outer step needs to *see all the inner results as separate numbers* (to average, max, median, etc.), it's nested. If the outer step just needs the *total ignoring some dimension*, it's LoD.
## More examples
- Cookbook: [Max user AOV by country](/as-code/aql/cookbook/aql-nested-aggregation): a cross-model nesting walkthrough (order items → user grain → country max).
## Next
→ [Time comparisons](/as-code/aql/learn/time-comparisons): the last big context modifier.
---
## The pipe operator
The pipe operator `|` takes whatever's on the left and passes it as the first argument to whatever's on the right. It's the syntax that makes AQL feel composable instead of nested.
```aml
// These two are equivalent:
users | avg(users.age)
avg(users, users.age)
```
:::tip Try it interactively
Practice in the AQL Playground: [Pipe (1)](https://play.amql.org/learn/pipe) · [Pipe (2)](https://play.amql.org/learn/pipe-2).
:::
## Why it matters
AQL is composable by design: every function takes input, produces output, and that output can feed straight into the next function. You don't strictly need pipe for that. `sum(filter(orders, orders.country = 'Singapore'), orders.total_value)` works fine. But it reads inside-out, and three or four steps deep it stops being readable.
Pipe is what makes that composability ergonomic. SQL forces one shape (`SELECT ... FROM ... WHERE ... GROUP BY ...`); AQL lets you break a query into small steps and chain them, so the code reads in the same order you think about it: start with a table, narrow it down, aggregate. The pipe is the signal that AQL is built to be read left-to-right, step by step.
## Example
Say you want **total order value for Singapore**. You need two steps:
- [`filter()`](/reference/aql/filter): keep only Singapore orders.
- [`sum()`](/reference/aql/aggregator-functions#sum): add up the totals.
With pipes:
```aml
orders
| filter(orders.country = 'Singapore')
| sum(orders.total_value)
```
Read it top to bottom: *take orders, keep only Singapore, sum the totals*. Each pipe feeds its left side into the next function's first argument.
:::note Mental model
Each step that takes a table walks it **one row at a time**. `filter(orders.country = 'Singapore')` checks the condition on each row; `sum(orders.total_value)` adds the value column row by row. Whenever you write an expression like `orders.country` or `orders.quantity * orders.price`, it's evaluated against whichever row the step is currently looking at (the **current row**). This per-row evaluation is the foundation that filter, group, and aggregate all build on.
:::
## Tables in, scalars out
Every AQL expression produces either a **table** (rows you can keep filtering or grouping) or a **scalar** (a single value, the end of a chain). The pipe respects that:
- `orders` → a table. You can keep piping.
- `orders | filter(...)` → still a table. Keep piping.
- `orders | sum(orders.total_value)` → a scalar. The chain ends. There's nothing left to filter.
If a step expects a table and you hand it a scalar (or vice versa), the chain breaks. Once you've internalized "what shape is this step's output?", reading AQL gets a lot easier.
## When pipes shine
- Long chains (3+ steps): readable top-to-bottom instead of nested.
- Reusable fragments: `users | filter(users.is_active)` can be a building block.
- Debugging: comment out a single line to see the intermediate result.
For the full signature and edge cases, see the [pipe reference](/reference/aql/operator#pipe).
## Next
→ [Filtering](/as-code/aql/learn/filtering): `where()` vs `filter()`, and when each one applies.
---
## Tables and rows
AQL is a data query language. Like SQL or Excel, it operates on **tables**: collections of rows, where each row has columns. Before we touch any function, it helps to get precise about what AQL actually does as it reads your data.
:::tip Try it interactively
Practice in the AQL Playground: [Metric Expressions](https://play.amql.org/learn/metric-expressions).
:::
## A tiny example
Imagine an `order_items` table:
| id | order_id | product | quantity | price |
|----|----------|---------|----------|-------|
| 1 | 1001 | Mug | 2 | 10 |
| 2 | 1001 | Pen | 5 | 2 |
| 3 | 1002 | Book | 1 | 25 |
Three rows, five columns. AQL operates on tables like this one.
## The current row
Unlike Excel, you can't point at a single cell. The smallest thing AQL works on is a **row**. When AQL processes a table, it walks through the rows one at a time. The row it's currently looking at is called the **current row**.
Inside the current row, you refer to columns by name. So `order_items.quantity` means "the quantity column of whatever row I'm on right now". As AQL walks the table above, that expression evaluates to `2`, then `5`, then `1`.
You can combine columns too. `order_items.quantity * order_items.price` is a **row-level expression**: it runs once per row, giving `20`, then `10`, then `25`.
## Iteration
That walk-through-the-rows process is called **iteration**. Most AQL functions iterate. A few you'll meet next:
- [`select`](/reference/aql/select): walks rows, produces a new table with just the columns you ask for.
- [`filter`](/reference/aql/filter): walks rows, keeps only the ones matching a condition.
- [`sum`](/reference/aql/aggregator-functions#sum): walks rows, adds up a column into one number.
For example, the total revenue across the table:
```aml
order_items | sum(order_items.quantity * order_items.price)
// walks all 3 rows, computes 20 + 10 + 25 = 55
```
Don't worry about the `|` yet (that's the next page). The point for now: any expression you hand to one of these functions is evaluated **once per row**.
## Next
→ [The pipe operator](/as-code/aql/learn/pipe): how AQL chains operations together.
---
## Time comparisons
"Revenue this year vs last year" is one of the most common things a dashboard needs. In SQL it's an annoying self-join. In AQL it's a context modifier: take your existing metric, shift its time context, compare.
:::tip Try it interactively
Practice in the AQL Playground: [Function: date_trunc](https://play.amql.org/learn/function-date_trunc) · [Date/Time Expressions](https://play.amql.org/learn/datetime-expressions).
:::
## relative_period(): shift the metric back in time
`relative_period(, interval(-1 year))` says "evaluate this metric one year earlier than the surrounding report".
```aml
metric revenue_previous_year {
definition: @aql
sum_revenue
| relative_period(orders.created_at, interval(-1 year))
;;
}
```
If the report's looking at March 2024, this evaluates to March 2023. If it's looking at Q3 2024, this becomes Q3 2023. The metric automatically adapts to whatever time granularity the report uses.
## Building period-over-period from there
Once you have the shifted metric, the comparison metrics are just arithmetic:
```aml
metric revenue_yoy_change {
definition: @aql sum_revenue - revenue_previous_year ;;
}
metric revenue_yoy_pct {
definition: @aql
safe_divide(
(sum_revenue - revenue_previous_year) * 100.0,
revenue_previous_year
)
;;
}
```
Drop `revenue_yoy_pct` into a monthly trend chart and every month shows its YoY change. Drop it into a quarterly view and every quarter does. Same metric definition.
## Other time modifiers
| Want… | Function |
|---|---|
| Shift back by an interval | [`relative_period`](/reference/aql/relative_period) |
| Compare to the same date in a previous year/quarter | [`exact_period`](/reference/aql/exact_period) |
| Year-to-date / month-to-date | [`period_to_date`](/reference/aql/period_to_date) |
| Rolling window (last N days) | [`trailing_period`](/reference/aql/trailing_period) |
| Running total | [`running_total`](/reference/aql/running_total) |
| Compare to the previous row's value | [`previous`](/reference/aql/previous) |
## Why this is one definition, not many
The pattern is always the same: take an existing metric, pipe it through a time modifier, get a comparable metric. You never duplicate the underlying aggregation, you never write a self-join, and you never tie the metric to a specific year.
This is the payoff of [metric context](/as-code/aql/learn/metric-context): once you understand context as a thing you can override, period comparisons are just one specific kind of override.
## What's next after the arc
You've now seen every major piece of AQL. From here:
- **[Examples & Recipes](/as-code/aql/cookbook/metrics-by-example)**: short, copy-paste patterns on a shared e-commerce schema.
- **[Reference](/reference/aql/function)**: function signatures.
- **[AQL Best Practices](/as-code/aql/best-practices)**: what to do and not do.
---
## What AQL is for
Before we touch pipes, filters, or grouping, it's worth seeing the destination. AQL exists to do one thing well: **define metrics once, then query them anywhere.**
## A metric in 10 lines
A **metric** is a named, reusable aggregation. Here's one:
```aml
metric total_revenue {
label: 'Total Revenue'
type: 'number'
definition: @aql sum(order_items.quantity * products.price) ;;
}
```
That's it. No `GROUP BY`, no `JOIN`, no per-report scaffolding. Just the calculation. The metric travels with its definition.
## Same metric, three reports
Drop `total_revenue` into three different reports and it adapts to each:
```aml
// Report 1: no grouping → grand total
explore { measures { total_revenue } }
// → $4,283,512
// Report 2: grouped by country → revenue per country
explore { dimensions { countries.name } measures { total_revenue } }
// → US: $2.1M, UK: $890K, ...
// Report 3: grouped by month, filtered to 2024 → monthly trend
explore {
dimensions { orders.created_at | month() }
measures { total_revenue }
filters { orders.created_at matches @2024 }
}
// → Jan 2024: $312K, Feb 2024: $358K, ...
```
Same definition. Three different numbers, three different shapes. That's the part SQL can't do cleanly. In SQL you'd write three separate queries with copy-pasted aggregation logic, and any change to "what revenue means" would mean editing all three.
## What the rest of Foundations teaches
To write metrics like `total_revenue`, you need to know the primitives that go inside the definition:
- **[The pipe operator](/as-code/aql/learn/pipe)**: how steps chain together.
- **[Filtering](/as-code/aql/learn/filtering)**: narrowing rows before or inside an aggregation.
- **[Grouping and aggregation](/as-code/aql/learn/grouping)**: turning many rows into a single number.
Once those click, the [Metrics](/as-code/aql/#learning-roadmap) group covers how metrics interact with related models, how to name and reuse them, and how the surrounding report context shapes what they return.
## Next
→ [The pipe operator](/as-code/aql/learn/pipe): the single biggest reason AQL reads differently from SQL.
---
## AQL in 30 minutes
This is the fast-track tour. Read top to bottom in about 5 minutes for a working mental model; click into any Learn page when you want the full treatment.
For the slow path with one concept per page, see the **[learning roadmap](/as-code/aql/#learning-roadmap)** on the AQL overview.
## Prerequisites
AQL works against [AML](/reference/aml/) data models. You'll need a dataset with a few models and relationships defined. Quick example:
```aml
Model users { dimension id {} dimension gender {} dimension age {} }
Model countries { dimension id {} dimension name {} }
Dataset e_commerce {
models: [users, countries]
relationships: [relationship(users.country_id > countries.id, true)]
}
```
## 1. The pipe operator
`|` chains operations left-to-right. `x | f(args)` is the same as `f(x, args)`.
```aml
users | avg(users.age) // average age of all users
```
→ [Full page](/as-code/aql/learn/pipe)
## 2. Filtering
Two filters:
- **`filter()`** narrows a table (like SQL `WHERE`).
- **`where()`** narrows a metric (the filter rides with the aggregation).
```aml
// Drop rows before aggregating
users | filter(users.gender == 'Female') | count(users.id)
// Bake the filter into a metric
count(users.id) | where(users.gender == 'Female')
```
→ [Full page](/as-code/aql/learn/filtering)
## 3. Grouping and aggregation
`group()` collapses by a dimension; the next aggregator gives you one number per group.
```aml
users
| group(users.gender)
| select(users.gender, count(users.id))
```
→ [Full page](/as-code/aql/learn/grouping)
## 4. Cross-model queries
Because relationships are declared in the dataset, you reference columns from related models without writing joins:
```aml
users | select(users.name, countries.name)
```
→ [Full page](/as-code/aql/learn/cross-model)
## 5. Defining a metric
Name an aggregation so you can reuse it everywhere:
```aml
Model users {
measure user_count {
definition: @aql count(users.id) ;;
}
}
```
Now `users | group(users.gender) | select(users.gender, users.user_count)` works, and the metric adapts to whatever the surrounding report groups by.
→ [Full page](/as-code/aql/learn/defining-a-metric)
## 6. Metric context
A metric doesn't carry a fixed number. It evaluates against the surrounding dimensions, filters, relationships, and time window. The "advanced" parts of AQL are mostly about *overriding* one piece of that context.
→ [Full page](/as-code/aql/learn/metric-context)
## 7. Level of detail
Make a metric ignore some grouping (`of_all`), force a fixed grain (`exclude`), or turn an aggregation into a dimension (`dimensionalize`). Classic use: percent-of-total.
```aml
count(users.id) / (count(users.id) | of_all(countries))
```
→ [Full page](/as-code/aql/learn/level-of-detail)
## 8. Nested aggregation
When the answer needs aggregation-of-aggregation (average monthly new customers, best month for revenue):
```aml
users | group(users.created_at | month()) | select(count(users.id)) | average()
```
→ [Full page](/as-code/aql/learn/nested-aggregation)
## 9. Time comparisons
Shift a metric back in time and the comparison just becomes arithmetic:
```aml
metric revenue_yoy_pct {
definition: @aql
safe_divide(
(revenue - (revenue | relative_period(orders.created_at, interval(-1 year)))) * 100,
revenue | relative_period(orders.created_at, interval(-1 year))
)
;;
}
```
→ [Full page](/as-code/aql/learn/time-comparisons)
## Where to go next
- **[Examples & Recipes](/as-code/aql/cookbook/metrics-by-example)**: copy-paste-ready patterns on a shared e-commerce schema.
- **[Reference](/reference/aql/function)**: every function and operator.
---
## Order of Operations
:::tip Short version
Filters on **dimensions** are applied *before* aggregation. Filters on **metrics** are applied *after*. When a metric uses [`of_all()`](/reference/aql/of_all) to exclude a dimension, filters on that excluded dimension are ignored too. That's the gotcha this page exists for.
:::
## The pipeline
Every time AQL produces a result, this sequence runs:
1. Create model [CTEs](https://www.atlassian.com/data/sql/using-common-table-expressions).
2. Apply [query params](/docs/query-parameters) (for [query models](/docs/query-models) only).
3. Execute AQL dimensions.
4. **Ignore excluded dimensions from filters**: filters on dimensions that a metric excludes via `of_all`/`exclude` are dropped *for that metric*.
5. Apply filters to dimensions that aren't excluded.
6. Execute aggregations and metric logic.
7. Apply any filters on measures and metrics.
Step 4 is the one that bites most people. The rest of this page is one worked example showing why.
## Example: Total Orders per Category for a specific Merchant
Setup: an e-commerce store with orders, order items, products, merchants, and categories.

You want two metrics side by side:
- **Total Orders**: `count(orders.id)`
- **Total Orders per Category**: `count(orders.id) | of_all(merchants.name)`. This ignores the Merchant dimension so the per-category total stays constant across merchants.
```aql
// Total Orders
count(orders.id)
// Total Orders Per Category (ignores Merchant)
count(orders.id) | of_all(merchants.name)
```

### The gotcha: filtering Merchant Name still shows empty merchants
Filter the report to `merchants.name == 'Abernathy Group'`. You'd expect only Abernathy rows. Instead, you also see categories with **empty** merchant names:

This is step 4 in action. `Total Orders per Category` excludes `merchants.name` (via `of_all`), so the filter on `merchants.name` is dropped *for that metric*. The metric computes across all merchants and then gets COALESCE'd onto category rows where Abernathy has no orders.
Generated SQL (abridged)
```sql
-- "Abernathy" branch: filter applied
WITH category_orders_abernathy AS (
SELECT categories.name AS category_name,
merchants.name AS merchant_name,
COUNT(orders.id) AS order_count
FROM ecommerce.order_items
LEFT JOIN ecommerce.products ON order_items.product_id = products.id
LEFT JOIN ecommerce.categories ON products.category_id = categories.id
LEFT JOIN ecommerce.merchants ON products.merchant_id = merchants.id
LEFT JOIN ecommerce.orders ON order_items.order_id = orders.id
WHERE merchants.name = 'Abernathy Group'
GROUP BY categories.name, merchants.name
),
-- of_all branch: filter dropped because merchants.name is excluded
all_category_orders AS (
SELECT categories.name AS category_name,
COUNT(orders.id) AS total_orders
FROM ecommerce.order_items
LEFT JOIN ecommerce.products ON order_items.product_id = products.id
LEFT JOIN ecommerce.categories ON products.category_id = categories.id
LEFT JOIN ecommerce.orders ON order_items.order_id = orders.id
GROUP BY categories.name
)
SELECT COALESCE(a.category_name, b.category_name) AS category_name,
a.merchant_name,
MAX(a.order_count) AS total_orders_abernathy,
MAX(b.total_orders) AS total_orders_all_merchants
FROM category_orders_abernathy a
FULL JOIN all_category_orders b USING (category_name)
GROUP BY 1, 2;
```
## Two ways to fix it
Both solutions move the merchant filter out of step 5 (where it's dropped) and into step 7 (where it always runs).
### Solution 1. filter via a metric
Create a metric that returns the merchant name, then filter on *that* metric instead of the dimension.

Because the filter is now on a metric, it lands in step 7, applied to the final result after both branches have computed.
Generated SQL (abridged)
```sql
WITH combined_counts AS (
-- … both branches computed as before, no merchant filter pushed down …
SELECT category_name,
merchant_name,
total_orders,
total_users_per_category,
merchant_name AS merchant_name_metric
FROM /* joined CTEs */
)
SELECT *
FROM combined_counts
WHERE merchant_name_metric = 'Abernathy Group';
```
### Solution 2. bake the condition into the metric
If you don't want a separate filter metric, wrap the calculation in `case()`:
```aql
case(
when: merchants.name == 'Abernathy Group',
then: count(orders.id) | of_all(merchants.name),
else: null
)
```
Now the metric returns null for every merchant *except* Abernathy. Then filter "is not null" on the metric. Again, that filter runs in step 7.

Generated SQL (abridged)
```sql
WITH combined_counts AS (
SELECT category_name,
merchant_name,
MAX(order_count) AS total_orders,
CASE
WHEN merchant_name = 'Abernathy Group'
THEN MAX(total_order_count)
ELSE NULL
END AS total_users_per_cate
FROM /* joined CTEs */
GROUP BY category_name, merchant_name
)
SELECT *
FROM combined_counts
WHERE total_users_per_cate IS NOT NULL;
```
## Window functions in dimensions evaluate before filters
Window functions in dimensions (`rank()`, `ntile()`, `previous()`, etc.) always run on the full underlying table at step 3, *before* any filter is applied at step 5. So a `rank` dimension defined on `orders` ranks across all orders, not just the ones visible in your report.
```aml
dimension revenue_rank {
model: orders
definition: @aql rank(order: orders.amount | desc()) ;;
// Ranks ALL orders. If the report is filtered to 2024,
// you may see non-consecutive ranks like 145, 203, 567.
}
```
Two patterns to limit the ranking to a filtered scope:
```aml
// Pattern 1: push the filter into the metric and dimensionalize
dimension revenue_rank_2024 {
model: orders
definition: @aql
rank(order: min(orders.amount) | where(orders.created_at >= @2024) | desc())
| dimensionalize(orders.id, orders.amount)
;;
}
// Pattern 2: use case() to control which rows participate
dimension revenue_rank_2024_only {
model: orders
definition: @aql
case(
when: orders.created_at >= @2024,
then: rank(order: case(
when: orders.created_at >= @2024,
then: orders.amount,
else: 999999999 // push non-2024 orders to the bottom
) | desc()),
else: null
)
;;
}
```
For broader LoD patterns (per-customer aggregates, percent of total), see [Level of Detail](/as-code/aql/learn/level-of-detail).
## FAQ
**Are filters on dimensions and metrics applied at the same time, since they live in the same UI panel?**
No. Dimension filters run before aggregation (step 5); metric filters run after (step 7). Same panel, different stages.
**Can I filter data *before* any dimension is computed?**
Yes. Use a [Query Model](/docs/query-models) with [Query Params](/docs/query-parameters). Those run at step 2, before anything else.
**Why does my rank dimension show non-consecutive numbers after I filter?**
Because window functions in dimensions evaluate at step 3, before filters at step 5. See [Window functions in dimensions](#window-functions-in-dimensions-evaluate-before-filters) above.
**How does this interact with PreAggregates?**
PreAggregates compute a materialized version of metrics ahead of time. They sit conceptually between step 6 and step 7. The aggregated rows are pre-baked, then filters in step 7 still apply against the result.
## See also
- [Level of Detail](/as-code/aql/learn/level-of-detail): `of_all`, `dimensionalize`, `exclude`
- [Metric Context](/as-code/aql/learn/metric-context): how filters and grouping flow into metrics
- [AQL Validation Rules](/as-code/aql/validation-rules): hard constraints
- [AQL Best Practices](/as-code/aql/best-practices): advisory guidance
---
## AQL Overview
## The paradigm shift: metrics as first-class objects
In most BI tools, a metric is a SQL string with some metadata. Useful for slicing and grouping, but the moment you need a period comparison, a cohort, a percent-of-total, or any composition of metrics, the abstraction breaks. You fall back to writing more SQL, building more models, or copying logic into spreadsheets. This is what causes the [semantic ceiling](/docs/difference#the-semantic-ceiling).
AQL (Analytics Query Language) takes a different approach. **Metrics are first-class composable objects**, defined once, then combined, transformed, and reused. Period comparisons, level-of-detail modifiers, ratios across grains, nested aggregations: these stay inside the metric layer instead of leaking into derived tables.
This paradigm shift is what makes the semantic layer **expressive**. And expressiveness is what makes it possible for both AI and human users to reason from your governed business definitions instead of raw schema. See [Why Holistics](/docs/difference) for the structural argument, and [Why Holistics AI is reliable](/docs/ai/architecture) for how AQL specifically enables trustworthy AI analytics.
## What AQL is
AQL is both a **query language and a metric definition language**. It uses the data semantic model defined with [AML](/reference/aml/) to query data at a higher level of abstraction, with composable metric-based queries as the centerpiece.
What you get:
- **Reusable metrics**: define once, use everywhere.
- **Context-aware**: metrics adapt automatically to filters, grouping, and time periods.
- **Composable**: build complex metrics from simpler ones.
- **Business-friendly**: express logic in terms users understand.
With AQL, you can implement analytics use cases that are typically painful in SQL: [percent of total](/as-code/aql/cookbook/aql-percent-of-total), [nested aggregations](/as-code/aql/cookbook/level-of-detail#use-case-1-higher-lod--nested-aggregation), [levels of detail](/as-code/aql/cookbook/level-of-detail), period-over-period comparisons, cohort retention, and running totals. All without falling back to custom SQL.
When executed, AQL compiles deterministically to SQL and works with most SQL databases. Currently supported: PostgreSQL, Amazon Redshift, Google BigQuery, Snowflake, Databricks, Microsoft SQL Server, Clickhouse, with more being added. The compiled SQL is inspectable, which makes it useful both for debugging and for building trust in AI-generated queries.
For a deeper structural argument on AQL versus SQL, see [AQL vs SQL](/as-code/aql/aql-vs-sql).
## A quick example
Here's a simple AQL metric that calculates revenue:
```aml
metric revenue {
label: 'Revenue'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
```
This metric can be used in any report, automatically adapting to whatever dimensions, filters, or time periods are applied to it.
## Why AQL
In the previous version of Holistics' semantic layer (3.0), [**metrics were defined using SQL**](https://docs-v3.holistics.io/docs/model-fields#adding-measures-to-data-model). This approach had the advantage of being familiar to data analysts and addressed SQL's scalability and reusability issues. However, our experience with common yet more advanced analytics use cases (such as [time comparisons](/reference/aql/metric-function#time-based-functions) and metrics with varied [levels of detail](/as-code/aql/cookbook/level-of-detail)) reveals that the effort required is still not ideal.
To solve those use cases, instead of working directly with the SQL definition of a metric, users often need to return to [build additional models and datasets](https://www.holistics.io/blog/metrics-deserve-better-composition/#use-case-metric-with-nested-aggregation) as a foundation for the metric to function properly. This not only increases the maintenance burden for data analysts but also requires business users to remember to use the correct dataset for each specific use case. This adds mental friction to the adoption of self-service analytics.
The root cause of the problem is due to the [limitation of SQL in terms of composability](https://www.holistics.io/blog/metrics-deserve-better-composition/#composition-in-sql). Ideally, we want a solution where metrics can be **easily composed** to solve common analytics use cases above, **without the need to constantly go back to SQL** to build additional models and datasets. It should be **simple enough** that even more savvy business users can use. Yet, it needs to be able to **compile to SQL** to fit with Holistics' SQL-based approach. **AQL was conceived as that ideal solution.**
## Design principles
- **Locality**: Common metrics-based analytics use cases can be done **locally within the metric definition**, without the need to modify the underlying data models and datasets using custom SQL.
- **Composability**: Metrics logic can be [broken down into smaller components and incrementally composed](/reference/aql/operator#pipe). This enhances maintainability and reusability.
- **Built-in analytical abilities**: Most common metrics analytics use cases can be done using a [single pre-built AQL function](/reference/aql/metric-function); more complex ones are composed from a few functions.
- **Instant feedback**: A strong type system and IDE allow a faster feedback loop during development compared to SQL.
- **Query performance**: Semantic understanding of metrics enables more [fine-grained performance optimization](/docs/aggregate-awareness).
- **Complement, not a replacement of SQL**: AQL is not designed to replace SQL, but to cover [SQL's gap](https://www.holistics.io/blog/metrics-deserve-better-composition/#composition-in-sql) when it comes to metrics.
## Learning roadmap
If you want the firehose version, start with **[AQL in 30 Minutes](/as-code/aql/learn-in-30-minutes)**: a skim-friendly tour of every concept.
For the slow path, work through these pages in order. Each one introduces a single concept with a small, working example. Every page also has a matching hands-on exercise in the [AQL Playground](https://play.amql.org/learn/welcome).
**Foundations**
1. **[What AQL is for](/as-code/aql/learn/what-aql-is-for)**: the destination: metrics defined once, queried anywhere.
2. **[The pipe operator](/as-code/aql/learn/pipe)**: `|` chains steps left-to-right.
3. **[Filtering](/as-code/aql/learn/filtering)**: `where()` vs `filter()`.
4. **[Grouping and aggregation](/as-code/aql/learn/grouping)**: turn many rows into one number per group.
5. **[Cross-model queries](/as-code/aql/learn/cross-model)**: reference columns from related models without writing joins.
6. **[Defining a metric](/as-code/aql/learn/defining-a-metric)**: name an aggregation so you can reuse it.
7. **[Metric context](/as-code/aql/learn/metric-context)**: why the same metric returns different numbers in different reports.
**Advanced patterns**
8. **[Level of detail](/as-code/aql/learn/level-of-detail)**: `of_all()`, `exclude()`, `dimensionalize()`.
9. **[Nested aggregation](/as-code/aql/learn/nested-aggregation)**: aggregate already-aggregated data.
10. **[Time comparisons](/as-code/aql/learn/time-comparisons)**: period-over-period without rewriting the metric.
Then **[Where to Define AQL](/as-code/aql/where-to-define-aql)** for the model-vs-dataset-vs-ad-hoc decision. Coming from SQL? Read **[AQL vs SQL](/as-code/aql/aql-vs-sql)** and **[Learn AQL from SQL Background](/as-code/aql/from-sql)**.
For copy-paste-ready patterns, jump to **[Examples & Recipes](/as-code/aql/cookbook/metrics-by-example)**. For function and operator signatures, see the **[Reference](/reference/aql/function)** top-nav.
---
## AQL Validation Rules
This page lists the constraints AQL enforces. These rules either fail validation or produce wrong results when broken. For advisory guidance on writing maintainable AQL, see [AQL Best Practices](/as-code/aql/best-practices). For the mental model of when filters and metrics fire, see [Order of Operations](/as-code/aql/order-of-operations).
## Model rules
**Dimension types must match the underlying column type**
Mismatched types cause runtime errors and incorrect results. Holistics does not auto-cast because consistent casting between incompatible types (text to datetime, for example) isn't always possible. Silent casts also hide performance and correctness problems.
```aml
// ✅ Good
dimension created_at {
type: 'datetime' // matches timestamp column
}
// ❌ Bad
dimension created_at {
type: 'text' // timestamp column declared as text
}
```
## Relationship rules
**Relationships must match the actual data cardinality**
Marking a many-to-many as many-to-one produces wrong aggregations (fan-out, double-counting). Verify cardinality against the data, not against your assumption of the schema.
```aml
// ✅ Good: verified many-to-one
relationship(orders.user_id > users.id, true)
// ❌ Bad: actually many-to-many
relationship(users.id > products.id, true)
```
**Both keys referenced in a relationship must exist**
Missing keys fail validation.
```aml
// ✅ Good
Model orders { dimension user_id { type: 'number' } }
Model users { dimension id { type: 'number', primary_key: true } }
relationship(orders.user_id > users.id, true)
// ❌ Bad: customer_id doesn't exist on orders
relationship(orders.customer_id > users.id, true)
```
**The "one" side of a relationship must have unique values**
Fan-out prevention relies on uniqueness of the key on the "one" side. Non-unique keys produce incorrect aggregations.
```aml
// ✅ Good: cities.id is a primary key
relationship(users.city_id > cities.id, true)
// ❌ Bad: cities.name isn't unique (many cities share names)
relationship(users.city_name > cities.name, true)
```
**Relationship paths between models must be unambiguous**
Multiple paths between the same pair of models create ambiguity AQL can't resolve. See [Common Relationship Problems](/as-code/reference/common-relationships-problems) for worked examples.
```aml
// ❌ Bad: circular reference creates two paths between orders and users
relationship(orders.user_id > users.id, true)
relationship(users.current_order_id > orders.id, true)
// ✅ Good: clear hierarchy
relationship(orders.user_id > users.id, true)
relationship(order_items.order_id > orders.id, true)
```
## Dimension expression rules
**Dimensions can only reference fields from accessible models**
A dimension can reach its own model's fields plus any model connected via a many-to-one (or chain of many-to-one) relationship. Reaching the "many" side directly fails.
```aml
// ✅ Good: direct many-to-one
dimension customer_name {
model: orders
definition: @aql users.name ;;
}
// ✅ Good: transitive many-to-one chain
dimension customer_city {
model: orders
definition: @aql cities.name ;; // orders → users → cities
}
// ❌ Bad: trying to reach the many side
dimension user_orders {
model: users
definition: @aql orders.id ;; // a user has many orders
}
// ❌ Bad: no relationship path
dimension product_category {
model: users
definition: @aql products.category ;;
}
```
**Use `dimensionalize()` to access the many side of a relationship**
To pull aggregated information from the "many" side into a dimension, wrap with [`dimensionalize()`](/reference/aql/dimensionalize). This collapses the aggregation to a fixed grain so the result behaves as a dimension.
```aml
// ✅ Good
dimension total_spent {
model: users
definition: @aql sum(orders.amount) | dimensionalize(users.id) ;;
}
// ❌ Bad: direct access to many side
dimension last_order_amount {
model: users
definition: @aql orders.amount ;;
}
```
**`dimensionalize()` must appear once, at the top level**
Nesting `dimensionalize()` inside other expressions or applying it more than once produces undefined behavior.
```aml
// ✅ Good
dimension customer_lifetime_value {
model: users
definition: @aql sum(orders.amount) | dimensionalize(users.id) ;;
}
// ❌ Bad: nested dimensionalize
dimension complex_calc {
model: users
definition: @aql
sum(orders.amount) | dimensionalize(users.id) +
count(reviews.id) | dimensionalize(users.id)
;;
}
// ❌ Bad: dimensionalize not at top level
dimension avg_order_by_category {
model: categories
definition: @aql
avg(sum(orders.amount) | dimensionalize(users.id))
;;
}
```
## Metric expression rules
**Cross-model aggregations must specify a source table**
When the expression combines fields from multiple models, AQL can't infer which table sets the aggregation grain. State it explicitly.
```aml
// ✅ Good: explicit source table
metric revenue {
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
// ❌ Bad: ambiguous source
metric revenue {
definition: @aql sum(order_items.quantity * products.price) ;;
}
```
**`group()` must be followed by `select()` or `filter()`**
Aggregate functions can technically follow `group()` directly, but it raises [WARN-300](/reference/aql/error-reference#ERR-300) and obscures the intermediate table. Use `select()` or `filter()` to keep the grouped table explicit.
```aml
// ✅ Good
metric avg_items_per_order {
definition: @aql
orders
| group(orders.id)
| select(item_count: count(order_items.id))
| avg(item_count)
;;
}
// ⚠️ Triggers WARN-300
metric avg_items_per_order {
definition: @aql
orders
| group(orders.id)
| avg(count(order_items.id))
;;
}
```
**Fields referenced in LoD functions must exist and be in scope**
`of_all()`, `exclude()`, and `dimensionalize()` arguments must resolve to a real dimension reachable from the current model context.
```aml
// ✅ Good
metric percent_of_total {
definition: @aql safe_divide(revenue * 100, revenue | of_all(products)) ;;
}
// ❌ Bad: 'product' doesn't exist; the model is 'products'
metric percent_of_total {
definition: @aql safe_divide(revenue * 100, revenue | of_all(product)) ;;
}
```
## See also
- [AQL Best Practices](/as-code/aql/best-practices): advisory guidance and pitfalls
- [Order of Operations](/as-code/aql/order-of-operations): how AQL evaluates filters, metrics, and LoD
- [Error Reference](/reference/aql/error-reference): full list of validation error codes
- [Common Relationship Problems](/as-code/reference/common-relationships-problems): debugging ambiguous paths and fan-out
---
## Where to define AQL expressions
You can write AQL in four places: on a model, on a dataset, as a standalone `Metric`, or ad-hoc inside a dataset exploration. The right home depends on **scope** (which models the expression touches) and **reusability** (who else needs it).
## Decision table
| Type | Example | Best home | Why |
| --- | --- | --- | --- |
| **Simple aggregation / single-model metric** | Total orders | [Model: `measure`](#on-a-model) | Calculation lives where the data does; reusable wherever the model is reused |
| **Single-model dimension transform** | Age group from `users.age` | [Model: `dimension`](#on-a-model) | Travels with the model |
| **Cross-model metric** | Revenue per user | [Dataset: `metric`](#on-a-dataset) | Needs the dataset's relationships to join |
| **Cross-model dimension** | Customer segment based on purchases | [Dataset: `dimension`](#on-a-dataset) | Same reason: needs the join graph |
| **Metric reused across multiple datasets** | GMV shared by Sales, Finance, and Marketing datasets | [Standalone `Metric`](#as-a-standalone-metric) | Defined once, attached to any dataset via `extend` |
| **One-off analysis** | Q4 sales spike investigation | [Ad-hoc in exploration](#ad-hoc-in-an-exploration) | Not worth persisting |
## How the four placements compare
| | Model `measure` / `dimension` | Dataset `metric` / `dimension` | Standalone `Metric` | Ad-hoc in exploration |
|---|---|---|---|---|
| **Scope** | One model | Multiple models in the dataset | Any dataset that includes it | Single exploration |
| **Reuses across datasets** | ✅ Wherever the model is used | ❌ Bound to one dataset | ✅ Attach to any dataset | ❌ Single exploration |
| **Visible to end users** | ✅ | ✅ | ✅ Once attached to a dataset | Only the exploration's viewers |
| **Persists** | ✅ In source-controlled AML | ✅ In source-controlled AML | ✅ In source-controlled AML | Only if saved as a report |
| **Who edits** | Data team (modeling layer) | Data team (modeling layer) | Data team (modeling layer) | Anyone with explore access |
## On a model
When the calculation only touches one model, put it on the model (as a `measure` for aggregation, or `dimension` for per-row transform). It then travels with the model wherever it's reused.
```aml
Model orders {
data_source_name: 'main'
dimension id { type: 'number', primary_key: true }
dimension amount { type: 'number' }
dimension status { type: 'text' }
dimension created_at { type: 'datetime' }
// Aggregation → measure
measure total_revenue {
type: 'number'
definition: @aql sum(orders.amount) ;;
}
// Per-row transform → dimension
dimension is_completed {
type: 'truefalse'
definition: @aql orders.status == 'completed' ;;
}
}
```
## On a dataset
When the expression spans multiple models, define it at the dataset level. The dataset is where the relationships live, so it's the only place AQL can resolve cross-model joins.
```aml
Dataset ecommerce {
models: [orders, order_items, products, users]
relationships: [
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(orders.user_id > users.id, true),
]
metric revenue_per_user {
type: 'number'
definition: @aql
safe_divide(
order_items | sum(order_items.quantity * products.price),
count_distinct(users.id)
)
;;
}
dimension customer_segment {
model: users
type: 'text'
definition: @aql
case(
when: (sum(orders.amount) | dimensionalize(users.id)) > 10000, then: 'VIP',
when: (sum(orders.amount) | dimensionalize(users.id)) > 1000, then: 'Regular',
else: 'New'
)
;;
}
}
```
## As a standalone Metric
When the same metric is needed across multiple datasets, declare it as a top-level `Metric` object. The definition lives independently of any dataset, and each dataset attaches it via `extend`. Change the definition once, and every dataset that references it picks up the update.
```aml title="metrics.aml"
Metric gmv {
label: "GMV (Gross Merchandise Value)"
type: "number"
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
format: "[\$\$]#,###0"
}
Metric total_orders {
label: "Total Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
```
Then attach the standalone metrics to any dataset that needs them:
```aml title="sales.dataset.aml"
Dataset sales_with_metrics = sales.extend({
metric gmv: gmv
metric total_orders: total_orders
})
```
The same `gmv` definition can be attached to a `finance` dataset, a `marketing` dataset, and so on. It's a single source of truth for the calculation. For organizing groups of related metrics (e.g. a thematic `PartialDataset` of finance metrics), see [Implement Reusable Metric Store](/as-code/aml/use-cases/extend-metric-store).
## Ad-hoc in an exploration
For a one-off question, write the AQL directly in a dataset exploration. The expression persists only if you save the exploration as a report. Good for quick analysis or for users who don't have modeling-layer access.
:::tip
If you don't see the option to define AQL expressions and your account signed up before April 19, 2024, you may need to [enable AQL for your datasets](/as-code/aql/enabling-aql).
:::
## See also
- [Defining a Metric](/as-code/aql/learn/defining-a-metric): conceptual intro
- [Implement Reusable Metric Store](/as-code/aml/use-cases/extend-metric-store): patterns for organizing standalone metrics
- [AML `Metric` object reference](/reference/aml/metric): full parameter list for standalone metrics
- [`metric` and `dimension` field reference](/reference/aml/field): full parameter list for in-model fields
- [AQL Best Practices](/as-code/aql/best-practices): guidance on metric and model design
---
## Cohort Analysis
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Cross-Model Reference](/as-code/aql/learn/cross-model)
- [Dimensionalize](/reference/aql/dimensionalize)
:::
## Introduction
**Cohort Analysis** is a methodology that involves studying and analyzing groups of users or customers who share a common characteristic or behavior.
We will provide a step-by-step walkthrough on how to build a report in Holistics that demonstrates the classic cohort analysis.
## Setup
In order to gain insights into the lifetime value of groups of users who were acquired for each particular period of time, our objective is to define and analyze Acquisition Cohorts.
For this tutorial, we will be using an e-commerce dataset that consists of two models: `orders` and `users`.
Before diving into the implementation, let's take a quick look at our dataset setup. Additionally, the dataset also includes a pre-defined metric called `revenue`, which will be utilized in this tutorial for further analysis.
```aml
// orders.model.aml
Model orders {
...
dimension id {...}
dimension user_id {}
dimension created_at {}
}
// users.model.aml
Model users {
...
dimension id {...}
dimension name {...}
}
// e_commerce.dataset.aml
Dataset e_commerce {
...
models: [orders, users]
relationships: [
relationship(orders.user_id > users.id, true)
]
metric revenue {...}
dimension acquisition_year_cohort {
model: users
type: 'date'
label: 'Acquisition Year Cohort'
definition: @aql orders
| min(orders.created_at | year())
| dimensionalize(users.id)
;;
}
metric percent_revenue {
label: 'Percent of Revenue'
definition: @aql (revenue*1.0) / (revenue | of_all(users.acquisition_year_cohort));;
}
}
```
_Sample data _
## High-level flow
1. **Define Acquisition Cohort:** Determine the period and timeframe when each customer was acquired. This period could be daily, monthly, yearly, or any other suitable duration based on your business needs.
2. **Define the metric:** Define the metric you want to observe for each cohort
3. **Bring it together:** Visualize the metrics with each cohort in the pivot table
## Implementation
### 1. Define Acquisition Cohort
Let’s define the acquisition cohort as the year that users made their first purchase.
Since `created_at` in model `orders` is involved in the expression, `orders` is needed to be placed before [pipe](/reference/aql/operator#pipe) further calculations. This allows `orders` left join `users` based on the relationship we've already defined in the dataset setup.
[`dimensionalize()`](/reference/aql/dimensionalize) is also applied to group users within the same acquisition period together
```aml
Dataset e_commerce {
(...)
dimension acquisition_year_cohort {
model: users
type: 'date'
label: 'Acquisition Year Cohort'
definition: @aql orders
| min(orders.created_at | year())
| dimensionalize(users.id)
;;
}
metric revenue {...}
}
```
### 2. Define the Metrics
Create a metric `percent_revenue` which is the [percent of total](/as-code/aql/cookbook/aql-percent-of-total) revenue per each _acquisition cohort_. This metric can also be defined directly on the reporting layer
```aml
Dataset e_commerce {
...
dimension acquisition_year_cohort {...}
metric revenue {...}
metric percent_revenue {
label: 'Percent of Revenue'
definition: @aql (revenue*1.0) / (revenue | of_all(users.acquisition_year_cohort));;
}
}
```
### 3. Bring it together
We will look at how each `Acquision Cohort` performs over the years. `Revenue` and `Percent of Revenue` metrics will be used to measure their performance
---
We have covered all the foundational concepts required to calculate metrics for the acquisition cohort, and you are now equipped to create even more powerful cohort analysis reports to present to your stakeholders in Holistics.
---
## Cross-Model Calculation
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Cross-Model Reference](/as-code/aql/learn/cross-model)
:::
## Overview
In most analytic setups, data are organized across multiple [models](/reference/aml/model). Thus, it is often necessary to perform calculations that involve data from multiple models. For example, in an e-commerce dataset, you may want to make calculations that involves data from orders, users, and products models. In AQL, this is called **cross-model calculation**.
In this guide, you will learn how to perform cross-model calculation in AQL. If you want to learn more about the concepts behind cross-model calculation, please refer to its [reference](/as-code/aql/learn/cross-model) page.
## Prerequisites
This guide assumes that you have a basic understanding of the following concepts:
- How to [define data models](/reference/aml/model)
- How to [define data sets](/reference/aml/dataset)
- How to [define relationships](/reference/aml/relationship)
## Setup
To start, visit the following AQL playground ([link](https://go.holistics.io/U6L8M)) and examine the `ecommerce` dataset. This dataset contains 4 models: `users`, `products`, `orders`, and `order_items`. The relationships between these models are defined as follows:
You are free to modify the dataset and the models as you see fit, or using different models and relationships in your own project. But for the purpose of this guide, we will use the `ecommerce` dataset as an example.
## Implementation
### Defining your first cross-model dimension
Let's say you want to create a dimension that contains the actual value of each order item in the `order_items` model with the following definition:
$$
\text{item value} = \text{item quantity} \times \text{product price}
$$
Examining the `order_items` model, you will see that it only has a `quantity` column and a `product_id` column, and does not contain the price of the product. Thus, you need to somehow access the price of the corresponding product in the `products` model.
In AQL, this is a simple matter of:
1. Define a relationship between the `order_items` model and the `products` model.
```aml
Dataset ecommerce {
/* ... */
relationships: [
relationship(order_items.order_id > orders.id, true),
// highlight-next-line
relationship(order_items.product_id > products.id, true),
/* ... */
]
}
```
2. Define the `value` dimension directly referencing `products.price` in the `order_items` model. (You can also define it at the [dataset level](/as-code/aql/where-to-define-aql#aql-dimensions))
```aml
Model order_items {
/* ... */
dimension value {
label: 'value'
type: 'number'
// highlight-next-line
definition: @aql order_items.quantity * products.price ;;
}
}
```
3. Test the new dimension.
### Defining your first cross-model metric
Similar to the previous example, let's say you want to create a metric to calculate the total value of all order items in the `order_items` model with the following definition:
$$
\text{total value} = \sum_{\text{item} \in \text{order items}} \text{item value}
$$
This time, since you already defined the relationship between the `order_items` model and the `products` model, you can simply use the [sum](/reference/aql/aggregator-functions#sum) function to aggregate the `value` dimension you defined in the previous example.
```aml
Model order_items {
/* ... */
// note that metrics defined in model are called measures
measure total_value {
label: 'Total Value'
type: 'number'
// highlight-next-line
definition: @aql sum(order_items.value) ;;
}
}
```
If you want, you can skip the `value` dimension and define the `total_value` metric directly.
```aml
Model order_items {
/* ... */
// note that metrics defined in model are called measures
measure total_value {
label: 'Total Value'
type: 'number'
// highlight-next-line
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
}
```
## Conclusion
In this guide, you have learned how to make cross-model calculation in AQL. If you want to learn more about the concepts behind cross-model calculation, please refer to its [reference](/as-code/aql/learn/cross-model) page.
---
## Relationship Ambiguity
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [with_relationships](/reference/aql/with_relationships)
:::
## Introduction
There may be instances when you need to disable certain relationships within your Dataset to avoid [ambiguities in the join path](/docs/joins/path-ambiguity). Yet, there are a lot of cases where the disabled relationships are required to compute a metric.
:::tip Measures and Metrics
You may see the use of `measure` in code snippets throughout this article. In Holistics, metrics defined inside data models are syntactically referred to as `measure`. We will use the word "metric" outside of code snippets to avoid confusions. [Read more about Metrics here](/as-code/aql/learn/what-aql-is-for)
:::
For clarity on these scenarios, please refer to the two examples provided below.
## Example 1: Role-playing Dimension
### What is Role-playing Dimension
In Star Schema design, a **role-playing dimensions** are dimensions that are used more than once in a fact table, each time with a different meaning or role.
In Holistics, this design can be imitated by creating multiple relationships between two tables. for example, in an Ecommerce company, the date dimension model has three relationships to the `Orders` facts. The same dimension table can be used to filter the facts by order date, delivery date, or cancelled date.
While this design is possible, it's important to understand that there can only be one active relationship between two Holistics models. All remaining relationships must be disabled to make this Dataset not being ambiguous.
Having a single active relationship means there is a default filter propagation from `date` dim model to `orders` fact model. In this instance, the active relationship is set to the most common filter that is used by reports, which is the `Date → Orders Created Date` and what you can only answer is how many orders has been created (instead of how many orders that has been `delivered` or `cancelled`)
### How to overcome this problem
The method is to handle inactive relationship is to use a function in AQL called `with_relationship()` ([Learn more here](/reference/aql/with_relationships)). The function literally telling Holistics that for this expression, use this relationship, even if it is inactive.
Let’s say that we want to understand how many Orders have been delivered, cancelled, refunded, you can create the metrics as below
```tsx
Dataset role_playing_dim {
models: [
fct_orders,
dim_dates
]
relationships: [
relationship(fct_orders.created_at > dim_dates.date, true),
relationship(fct_orders.cancelled_at > dim_dates.date, false),
relationship(fct_orders.delivered_at > dim_dates.date, false),
relationship(fct_orders.refunded_at > dim_dates.date, false)
]
// AQL Metrics
metric total_created_orders {
label: 'Total Created Orders'
type: 'number'
definition: @aql fct_orders | count(fct_orders.id);;
}
metric total_delivered_orders {
label: 'Total Delivered Orders'
type: 'number'
definition: @aql
fct_orders
| count(fct_orders.id)
| with_relationships(fct_orders.delivered_at > dim_dates.date)
;;
}
metric total_cancelled_orders {
label: 'Total Cancelled Orders'
type: 'number'
definition: @aql
fct_orders
| count(fct_orders.id)
| with_relationships(fct_orders.cancelled_at > dim_dates.date)
;;
}
metric total_refunded_orders {
label: 'Total Refunded Orders'
type: 'number'
definition: @aql
fct_orders
| count(fct_orders.id)
| with_relationships(fct_orders.refunded_at > dim_dates.date)
;;
}
}
```
And then when you use these metrics altogether, you will get the Result like below
```tsx
explore {
dimensions {
dim_dates.date
}
// Note that metrics defined in models are called measures
measures {
total_created_orders,
total_delivered_orders,
total_cancelled_orders,
total_refunded_orders
}
}
```
## Example 2: Fact Constellation Schema Design
### What is Fact Constellation Schema?
Fact Constellation Schema is also known as Galaxy Schema that further divides Star Schema in small Star Schema(s) where there are more than one Fact Tables and Reusable dimension to connect multiple Fact Tables.
For example, you could have a fact table for `orders` and another fact table for `inventory`, both linked to the same dimension tables for `products`, `dates`.
In Holistics, in order for this Dataset to be functional without facing the ambiguous error, you would have to disable 1 relationship from dim to fact. For example, in this case, I will disable the relationship from Products to Inventory
With this relationship setup, you’re unable to find out what are the Total Quantity Available for a specific product just by using the Drag and Drop in the Dataset Explore because the direct relationship between Products and Inventory is inactive.
This implies that, in the Dataset Explore, when you use the combination of Product Name and Sum of Quantity Available, instead of the filtering direction goes from `Products → Inventory`, it will go from `Products → Orders → Dates → Inventory` which is analytically incorrect.
### What is the solution?
What is you want to build a Report that tells
- Total Quantity Available by Product
- Total GMV by Product
- Filtered by Month
One of the solution can be done here is to use `with_relationship()` in the context of the metric Total Quantity Available by Product
```tsx
Dataset galaxy_schema {
models: [
fct_inventory,
dim_products,
fct_orders,
dim_dates
]
relationships: [
relationship(fct_orders.product_id > dim_products.id, true),
relationship(fct_inventory.product_id > dim_products.id, false),
relationship(fct_orders.created_at > dim_dates.date, true),
relationship(fct_inventory.created_at > dim_dates.date, true)
]
// AQL Metrics here
metric total_available_products {
label: 'Total Available Quantity Product'
type: 'number'
definition: @aql
fct_inventory
| sum(fct_inventory.quantity_available)
| with_relationships(fct_inventory.product_id > dim_products.id)
;;
}
}
```
And then when using it in the Dataset Explore to build the report, you will get the Result like below
```tsx
explore {
dimensions {
dim_products.name
}
// Note that metrics defined in models are called measures
measures {
total_available_products,
total_gmv_by_products: sum(fct_orders.item_values)
}
filters {
dim_dates.date matches @(last 6 months)
}
}
```
---
## Common Relationships Problems
## Unconnected Models
### Symptoms of Unconnected Models
#### Unresponsive Filters
When filters don't affect your metrics, it might indicate unconnected models.
#### Repeated Values
When adding a dimension causes metric values to repeat across every row.
### Understanding the Issue
This occurs when your dimension or filter's model lacks a relationship path to the metric's model.
### Solution
Create appropriate relationships between the models to establish the necessary connections.
### Why Are Unconnected Models Allowed?
Unconnected models serve legitimate use cases. Consider this example:
A currency conversion scenario:
- You have a `sales` model tracking transactions
- A separate `currencies` model stores exchange rates
- These models don't share natural relationships
But, you can use `currencies` in a metric of sales for currency conversion:
```ts title="Sales in selected currency"
sales * max(currencies.exchange_rate)
```
In this case:
- Filters on `sales` won't affect `max(currencies.exchange_rate)`
- Users can filter `currencies` to select their target currency
- This separation is intentional and useful
## Ambiguous Relationships
### Symptoms
#### SQL Warning
When ambiguous relationships are detected between selected dimensions, the generated SQL includes a warning comment highlighting this issue.
#### Unexpected Metric Results
When multiple paths active to link a metric to a dimension, AQL selects one path using a ranking algorithm. If the system chosen path differs from what you expect, the metric may return unexpected values. The selected path is indicated in comments above the SQL query.
#### Warning relationships view
When you add new relationships in the dataset, Holistics will automatically detect path ambiguity and alert you with a warning.
### Understanding the Issue
Ambiguous relationships occur when there are cycles (loops) of active relationships in your data model. For example:
In such cases, there are at least two possible paths between any two models in the cycle. When exploring data that involves multiple models, these multiple paths can lead to different interpretations of the relationships, potentially resulting in unexpected results.
### Solutions
#### 1. Disable Redundant Relationships
The simplest solution is to disable one of the relationships that creates a cycle, leaving only one relationship active by default.
#### 2. Explicitly Enable Relationships
When you need a specific relationship for a metric, enable it using [with_relationships](/reference/aql/with_relationships):
```aml
// Activate a specific relationship between order_items and products
sum(order_items.revenue) | with_relationships(order_items.product_id > products.id)
```
#### 3. Override Path Priority
If `with_relationships` doesn't use your desired relationship, it might be because the path that use it is in a lower tier than another active path. AQL ranks paths into tier based on performance and common use cases to avoid making crazy and expensive joins. To force the use of a specific path, you can disable the higher-priority path:
```aml
// Disable the default relationship between products and merchants
sum(order_items.revenue)
| with_relationships(relationship(products.merchant_id > merchants.id, false, 'two_way'))
```
#### 4. Modeling
You can also remove the ambiguity by duplicate models to remove cycle. E.g. Duplicate Cities and Countries models into dedicated models for Users and Merchants:
---
## AQL Troubleshooting
This is one of three documents aimed at helping answer common questions about AQL:
1. [Troubleshooting](/as-code/reference/troubleshooting) (this guide)
2. [AQL Error Reference](/reference/aql/error-reference)
This guide covers two main topics:
- How to [identify the source of an error](#narrow-down-error-source) when you're stuck
- How to [resolve common issues](#error-resources) and get unstuck
## Identify the Source of an Error {#narrow-down-error-source}
When faced with an AQL error, your first step should be to identify the exact source of the problem. Most errors can be traced to a specific field (metric or dimension) in your code.
The problematic code will typically be highlighted in red, within the definition of the field causing the error, as shown in this example:
This visual indication should provide enough information to help you fix most errors. If you need additional context or the error message isn't clear, consult the resources listed below.
## Helpful Resources {#error-resources}
### Documentation Resources
## Troubleshooting Errors
If your visualization returns an error, consult:
- [AQL Error Reference](/reference/aql/error-reference):
A comprehensive guide to AQL error codes with explanations and solutions. Error messages in AQL include direct links to relevant sections of this guide.
## Unexpected Results
If your visualization runs without errors but produces unexpected results, consider these common causes:
- [Ambiguous relationships](/as-code/reference/common-relationships-problems#ambiguous-relationships):
Your dataset may have multiple possible relationship interpretations. The system's chosen interpretation might differ from your intended logic.
- [Unconnected models](/as-code/reference/common-relationships-problems#unconnected-models):
If metrics remain unchanged when adding dimensions or filters, or if values repeat across rows, your dimension/filter models may not be properly connected to the metric's model. This prevents them from affecting the results.
- [Level of Details](/as-code/aql/cookbook/level-of-detail):
Using LOD functions can alter how metrics respond to dimensions and filters. Review your LOD expressions to ensure they align with your analysis goals.
### Testing and Reproduction
#### AMQL Online Sandbox
If the documentation doesn't resolve your issue, the [AMQL online sandbox](https://play.amql.org) can be invaluable for troubleshooting:
- Create a minimal reproduction of your problem
- Isolate specific AQL code segments to identify issues
- Test potential fixes in a controlled environment
Using the sandbox allows you to simplify complex queries and focus on the specific components causing errors, making it easier to find solutions.
---
## Dashboard best practices
This page outlines some best practices to follow when creating Holistics dashboards.
## Before you begin
Here are some principles to consider before you create a dashboard.
### User-First Design
When it comes to building and designing a dashboard, the user has to come first. Without taking into account who will be using the dashboard and what their goal is, you really can’t design a great dashboard.
### Styling and Color Design
Choosing a color palette that matches your organization’s brand creates a unified graphic language and experience for your users. You can also use color for easy-understandable color coding. For example, if you monitor traffic to your website you can show an increase in traffic in green and a decrease in red.
## Best practices to follow
### Organize your dashboard
The information you are displaying on your dashboard should be organized logically.
- Whatever data and insights are most important should get the prime real estate at the **top left** portion of your dashboard so it has the best chance of catching a user’s attention.
- The rest of the dashboard should be displayed based on priority. Underneath the main section should be supporting details or trends. Below that, you can provide data tables or more specific information.
### Separate the level of details
- Level 1: High-level dashboard - Optimize for comprehensiveness. Optimize the filters to be drilled through into Level 2 dashboards. The goal of this dashboard is to have trendlines inform you of the general direction of key metrics and patterns for deeper investigation.
- Level 2: Detailed Dashboard - Optimize for breakdown details of Level 1 dashboard (use drill-through feature). Used for understanding.
- Level 3: Action-Driven. Use when there are clear actions to be taken (data alerts etc). (Note: Data Alerts is still a work in progress).
### Chose the right charts for the job
To help you choose the right representation of your data, here’s our short summary to choose the correct types of data visualization for your report.
Moreover, Holistics provides some visualization types by default for common analytics use-cases such as:
- [Retention Heatmap](/docs/charts/cohort-retention) for Cohort Retention Analysis
- Native support with [Period-over-Period Comparison](/docs/period-comparison)
- [Metric sheets](/docs/charts/metric-sheets) help to keep all your metrics in one sheet and visualize it with sparklines
### Use the right format for easy reading
- Be consistent with chart scales on axes, chart dimension ordering, and also the colors used for dimension values within charts.
- Be sure to encode quantitative data nicely. Don’t exceed three or four numerals when displaying numbers. Display measures to one or two numerals left of the decimal point and scale for thousands or millions i.e. 5.2M, not 5,200,000.
- Try to avoid mixing levels of precision and time. Make sure that time frames are well understood. Don’t have one chart that has last month next to filtered charts from a specific month of the year.
- Also try to avoid mixing big and small measures on the same scale, such as on a line or bar chart. For example, one measure can be in the millions and the other measure in the thousands. With such a large scale, it would be difficult to see the differences of the measure that is in the thousands. If you need to mix, choose a visualization, like a combination chart, that allows the use of a second axis.
- Avoid cluttering your charts with data labels that are not needed. The values in bar charts, ***if large enough***, are usually well understood without displaying the actual number.
### Performance Tips
#### Troubleshooting
:::tip Important
Click [here](/docs/performance/troubleshooting) to learn about Performance Troubleshooting in Holistics.
:::
#### Reduce number of widgets
We wrote an article on Best practices to [improve Holistics reporting performance](/docs/performance#reduce-the-number-of-widgets-in-a-dashboard), but it’s worth reiterating here. The easiest way to make dashboards load faster is to ask for less data.
- You could also break up a dashboard into multiple dashboards to reduce the number of items that need to load on a single one. It is advisable to have a **maximum of 10 - 15 widgets** per dashboard for better report performance.
- If a user wants to dive deeper into the details related to a certain section of a dashboard, they should be able to easily navigate to a new page or dashboard with that data using [Drill Through](/docs/interactions/drill-through) and [Cross-filtering](/docs/cross-filtering).
#### Preload dashboard
See:
* [Preload Dashboard API](/api/v2/reference/dashboards-submit-preload)
#### Disable Dashboard auto-run
See [Dashboard auto-run](/docs/dashboards/settings#dashboard-auto-run)
## Other tips
- Your viewers can hide/unhide items on the chart by clicking on the legends
- In addition to customizing click behavior, you can use [Text widgets](/docs/canvas-dashboard/text-block) to provide additional context, links to related dashboards, questions, or other relevant sites. Moreover, let’s use the `HTML` in the Text widget to embed videos and images to your dashboard or just create some inline CSS to style the text.
## Common challenges
- Left out adding filters, and drill-throughs. Some users miss out on the ability to add filters to dashboards.
---
## Tips on Designing Models
## Introduction
In this document, you can find some tips and best practices in designing models in Holistics
## Declare reusable model relationships
Aside from the option to specify [ad-hoc model relationships](/docs/datasets/dataset-relationships) when building dataset, you can also declare [reusable model relationships](/reference/aml/relationship#defining-reusable-relationships) in a `relationships.aml` file
## When to use custom metrics
The Data Exploration UI already has most of the basic aggregation functions (sum, count, average...), and we are gradually introducing more complex but also commonly used aggregations (like running aggregations). In case your aggregation can be solved using the GUI, using them is recommended over custom-build metrics.
Custom metrics should only be used in case of complicated aggregation (like nested aggregation, or conditional aggregation...)
## Document your models
Adding [metadata](/docs/data-model#model-metadata) (table description, field description, etc...) to important models is a good way to help you and your teammates have immediate context to what the model is about, and how to use it.
## Hide fields that are not needed
It is important that your end-users are not confused by the large number of fields the models and datasets when they explore. When you hide a field, it is only hidden from the Exploration interface of Datasets but can still be seen when you edit your models.
You can hide a field by using the `hidden` parameter in the field definition.
Below are our guidelines for when it is usually better to hide them or leave them visible.
HIDE
DON’T HIDE
Join Keys/IDs and compound primary keys
Field used to sort other fields
Field is purely technical and is only created for some calculations
Have you created a measure that shows the same result as the actual value field? (i.e. The field Revenue can be hidden once you have a measure that calculates the revenue)
The field is intended to be used in a visualization
---
## Introduction
Data models are the building blocks of Holistics. Developing your data modeling skills will greatly improve your reports and outputs. This section includes some tactical advice for building a model with Holistics efficiently and easily.
For the basics, check out the docs on how our [modeling layer](/docs/modeling/) works. Here is the quick start workflow to do modeling with Holistics.
---
## Model & Field Naming Convention
## Introduction
How you name your models and fields is, by itself, a type of documentation. A clear naming convention will help both navigation and understanding of the data.
While there are several possible conventions that you can adopt, in this document, we offer some simple conventions for you to get started.
## Models & Datasets naming
This applies to both model and dataset files:
### General rules
- **Naming:**
- `use_lower_case_and_dash` For i.e: Use `first_name`, **not** `"First Name"`.
- Entity name in the name of files should be in **plural form**
- If verb is used in the name, they must follow the forms
- verb (active voice) - entity: `map_users_visitors`
- entity name - verb (passive voice): `search_results_unnested`
- **Labeling:** Since the label is what the end-user sees, it should be descriptive and easy to understand:
- Avoid too much abbreviation
- Remove prefixes and suffixes (like dim, facts...)
- `Use Upper Case And No Underscore`
### Staging models
- Staging models used to produce a final model. Multiple actions can be done here.
- These tend to be convenient models used for reporting purpose, not for exploration.
- Format: `stg_`
- Example:
- `stg_query_reports`
- `stg_dashboards`
- `stg_map_holistics_landing_visitors_trials`
### Assisting models
Assisting models should not be exposed to end-user, commonly perform one specific action.
The general name format is **`verb (present tense)- entity (plural)`**. Some common verbs:
- `map_`: use for identity stitching tasks
- `dedup_`: use to deduplicate records
- `unnest_`: Use to unnest the nested fields, and use the result to do other things
- Examples
- map_exchange_rates
- map_visitors_trial
- map_pipedrive_deals_holistics_tenants
- dedup_trial_submission_emails
- unnest_holistics_app_global_search_results
- unnest_trial_submissions_what_you_like
- unnest_trial_submissions_reporting_data_sources
Another format is **`entity (plural) - verb (passive voice)`**.
- For example:
- `visitors_trials_mapped`
- `trial_emails_deduped`
### Cleaned and reporting models
These data models are where we start applying business logic, and as a result, typically have heavier transformations than staging models.
Here is a guide of naming models which adapt Kimball's dimensional modeling techniques
#### `dim_` models
- Dim models represent certain objects/entities in our products. These models tend to be short and wide (few rows, many fields).
- Format: `dim_`
- Example:
- `dim_query_reports`
- `dim_dashboards`.
#### `fact_` models
- contains information about "transactions" or "interactions".
- Have measurements, is not necessary a raw, atomic event table
- Tend to be a long, narrow table
- Examples:
- fact_jobs
- fact_business_metrics_monthly
## Field naming & labeling
A model field has two things: Field name and field label.
Field names should follow stricter rules compared to field labels. This is applicable for anything that is analyst-facing instead of business user-facing.
### Field name
- Case: use `snake_case`
- Name fields consistently and represent values consistently across the model. For example:
- user vs. customer vs. member vs. account
- company vs. account vs. organization
- registration vs. creation
- In denormalized tables, should try to retain the original name of the table.
For example, `id`, `created_at`, `updated_at` field in table `users` should still be `id`, `created_at`, `updated_at` instead of `user_id`, `user_created_at`, `user_updated_at`
- In a normalized table (where you joined different tables to get info of an entity into another entity's model), ambiguous field names **should be made clear:**
For example, in a derived `query_report` model (that create a normalized table):
```sql
select
query_reports.id as query_report_id
, query_reports.name as query_report_name
, query_reports.user_id
, users.name as user_name -- `name` exists in both tables
from query_reports
left join users on query_reports.user_id = users.id
```
#### Dimensions
***Numeric Dimensions***
- General form `[aggregation name]_object_number`
- If the aggregated dimension is monhubspot_customer_statusetary: `[aggregation name]_value_amount`
- Aggregation name is optional
- Ratios: `x_per_y`
- Percentage: `pct_x_over_y`
For example:
```sql
user_stats: {
dimensions: {
user_id
, user_name
, query_reports_number -- Number of reports belong to this user
, avg_reports_per_day_number
, pct_models_in_dataset_number
, total_revenue_amount
, total_payment_this_monnth_amount
, total_active_days_number
}
, measures: {}
}
```
***Boolean Dimensions***
Dimensions that have boolean (TRUE/FALSE) type
- General form: `is_...`, `has_...`, `clicked_button_a`, `created_multi_ds_report`
- Verb tense depends on the context.
#### Measures
General form: `[aggregation name]_object`
- Average: `avg_`
- Sum: `total_` (Use Total instead of Sum because it sound more natural)
- Count: `count_`
- Ratios: `x_per_y`
- Percentage: `pct_x_over_y`
Example:
```sql
user_stats: {
dimensions: {
user_id
, user_name
}
, measures: {
count_created_users: @sql count(users.id)
, count_existing_users: @sql count(users.id if is not deleted)
, avg_activity_days: @sql avg(total_active_days_number)
}
}
```
### Field label
- Case: `Use Capital`
- Field label is what the end-user see when exploring the dataset, so it should be descriptive
- Fields that have the same base name across multiple models (id, created_at, updated_at...) should be labeled explicitly. For example:
- Field `jobs.created_at` should have label **Job Created At**
- Field `users.created_at` should have label **User Created At**
- Field `query_reports.created_at` should have label **Report Created At**
---
## Organize AML Project
## Introduction
How you organize your AML project is a type of documentation by itself, since it communicates the structure and hierarchy of your data. A clear project structure helps you easily navigate your own data in the future, and can also assist your teammates in discovering data with minimal instruction from you.
## General project structure
Technically we can have all of the `.aml` files in any organization we want. However, for consistency and ease of navigation, we can follow this structure:
```
.
|-- data_source
| |-- datasets
| | |-- name_by_team_or_use_cases
| |-- models
| | |-- name_by_source_applications
| | |-- name_by_use_cases
| | |-- name_by_team
| | |-- ...
| |-- dashboards
| |-- |-- dashboard_name.page.aml
...
```
**Notes:**
- `data_source`: Models and datasets from **different data sources** should be separated into different parent folders.
- There are many ways to group datasets, for example: by their user cases, or by the teams that they mainly serve
## Model organization
Here are some ideas to further organize your models in the `models` folder
- Prefix folder name with numbers: This way you can impose a logical order for you folders
- Separate table models and query models
Here is an example:
```
...
models
|-- 1. Base models
| |- customers
| |- sales
| |- base_model_n
|-- 2. Query models
| |- query_model_1
| |- query_model_2
|-- 3. Analytical models (Specific use-case query models)
| |- analytical_model_1
| |- analytical_model_2
|-- 4. Archive
```
---
## How to use Query Models
## Introduction
If you have specific business logic that cannot be simply captured using table models, relationships and custom fields, [Query Models](/docs/query-models) are usually the go-to answer. However, since Holistics does not aim to be a full-fledged data transformation tool, Query Models have several limitations and should not be over-used.
## Common use cases for Query Models
- Complicated joins of multiple models
- Certain pre-aggregations are required
- Window functions and regex
- Your base tables are too large it does not make sense to explore directly
- Persist the query results
:::info Note
Holistics's Query Model provides basic transformation and persistence functionality. If your business cases require many advanced transformation & data persistence capabilities, we recommend you use dedicated transformation tools to create efficient derived tables for direct querying.
:::
## Balance between fixed pre-aggregation models and explorable models
Commonly, pre-aggregation models caters to people who need a high-level view, with little need for exploration. Sometimes, pre-aggregation is needed to circumvent a missing feature in Holistics. Pre-aggregation also let analysts have more control over the calculations and can test the results easier, since they already have a fixed set of dimension - metrics combination, and they just need to test results on those sets.
On the other hand, this type of model will be restrictive for users who want to freely explore data and answer their own ad-hoc questions on the fly.
In other words, both types of models have their own places, their usages depends on the audience of your datasets and dashboards. There is no absolute right answer - deciding when and where to use these two models is a balancing act.
## Set up materialized views to automatically persist the results of complex query models
Complex query models can result in long query times.
You may consider using [Model Persistence](/docs/query-models#model-persistence) to create a physical table in your data warehouse on a schedule.
So queries using the model will only need to query this single pre-transformed table instead of all the referred tables and then transform the data. This might help in some cases to speed up query time.
---
## Tagging best practices
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Tags](/docs/find-organize/tags)
- [Content endorsement](/docs/find-organize/content-endorsement)
- [Content archiving](/docs/find-organize/archive)
:::
## Why use tags
As your BI platform grows, finding the right dashboards, datasets, or reports becomes harder. Tags create a shared language that helps your team quickly discover, trust, and collaborate around data. With tags, you can:
- **Search faster:** Use tags to search for related items.
- **Endorse trusted items:** Mark items as endorsed to signal that they’re trusted.
- **Archive outdated items:** Keep your workspace clean by hiding items that are no longer relevant.
## Tips to build tags
### Start small with a few tags
- **Start simple**: Don’t overthink when first creating your tag list. You don’t need to plan everything out beforehand; just start with a simple list of tags that can be applied to your current workspace.
- **Iterate along the way:** Tags can be refactored later to adapt to your organization's use cases. Remember, the tool should serve you, not the other way around.
:::tip
Use our [recommended tag list](/docs/find-organize/tags#2-how-to-set-up-your-tagging-system) to get a quick start.
:::
### Utilize built-in tags
Holistics supports some built-in tags with special mechanisms that you can start with:
- **`Endorsed` tag**: Mark content as trusted and reliable (See [setup guide](/docs/find-organize/content-endorsement#set-up)).
- **`Archive` tag**: Helps hide outdated objects without permanently deleting them (See [setup guide](/docs/find-organize/archive#set-up)).
### Organize tags into categories
With related tags, you can group them by categories, which helps users answer their questions when looking for items:
- **Topic tags:** What is this item about? For example: `topic/sales-ops`, `topic/customer-success`, `topic/call-center`, etc.
- **Status tags:** Is it ready to use? For example: `status/draft`, `status/review`, `status/active`, etc.
- **Owner tags:** Who is responsible for this? For example: `owner/finance-team`, `owner/sales`, `owner/data-team`, etc.
- **Issue tags:** Is there anything I should be aware of? For example: `issue/stale-data`, `issue/under-maintenance`, `issue/confidential`, etc.
:::tip
Use a prefix (e.g., `dept/finance`) to group related tags.
:::
### Combine with an automation workflow
Enhance your workflow by auto-adding or removing tags with the [Tagging System API](/api/v2/reference/tagging-system). Some of the example use cases that you can reference:
**Use case 1: Mark broken dashboards/datasets**
- When a pipeline fails, you can call the add tag API to mark affected dashboards/datasets (e.g., `issue/broken`).
- Once fixed, you can call the remove tag API to remove it.
**Use case 2: Schedule regular reviews**
In your workflow, you can set a schedule (e.g., monthly, quarterly) to call the add tag API to:
- Mark items that need to be reviewed (e.g., `action/need-review`)
- Assign the person in charge (e.g., `maintainer/James`)
# Your turn!
:::tip
Still don’t know what to do? Use our [recommended tag list](/docs/find-organize/tags#2-how-to-set-up-your-tagging-system) to get a quick start.
:::
Have a tagging pattern that works well for your team? **Share it with the community** to help others get started faster.
---
## Column-level permission
_(Or restricting user access control at the column level)_
## Introduction
Sometimes in a report/dashboard, you want to restrict access control at the column level, allow/disallow certain users to see certain columns. In Holistics, we call this Column-level Permission.
Consider some of these real-world use cases:
- **Restrict access based on users:** In a department (e.g sales), you want each salesperson to see others' data, but they can only see customer's information on the deals they own.
- **Restrict PII Data Access:** For reports with PII (personally identifiable information) data, you want to grant access to selected set of users. Other users can still see report data, but cannot see the PII-related columns.
Unlike [Row-level Permission](row-level-permission.md), this approach doesn't completely remove the rows for the users. Instead, it masks the column values (e.g., showing "(redacted)") for users who shouldn't see them.
## Use case
In this post, we'll share with you how to implement the PII access example above where:
- Managers have full PII access.
- At staff-level, PII access is granted on a case-by-case basis.
Once set up, whenever a user views the Orders report or dataset, appropriate PII check will take place to determine the user can see the customer information.
## Implementation
The key idea is to make the dimension's SQL definition dynamic based on user attributes. We achieve this using [AML If-else expression](/reference/aml/if-else) and [string interpolation](/reference/aml/string-interpolation) directly in a [Table Model](/docs/table-models).
Here's how it works:
1. **Create a user attribute** to determine PII access for each user
2. **Create a user group** and assign the attribute to grant access to specific users
3. **Use an AML expression** to check the user's PII access and return either the actual column reference or a redacted string literal, then inject it into the dimension's SQL definition via string interpolation
### Step 1: create user attribute `pii_access`
Create a [user attribute](/docs/admin/user-attributes) called `pii_access` with Number type. 0 will mean no access, and 1 will mean access.
### Step 2: create user group and assign attribute
Create a usergroup 'Managers'. Also set `pii_access` to 1 for this usergroup.
Add all manager-level users to this group.
### Step 3: define the PII logic and apply it to the dimension
Create a constant that checks the user's `pii_access` attribute and returns either a redacted string literal or the actual column reference, then apply it to the dimension's SQL definition using string interpolation.
```aml title="users.model.aml"
//highlight-start
const pii =
if (H.current_user.pii_access != 1) {
'\'(redacted)\''
} else {
'{{ #SOURCE.email }}'
}
//highlight-end
Model users {
type: 'table'
label: 'Users'
description: ''
data_source_name: 'demodb'
...
dimension id {
label: 'Id'
type: 'number'
definition: @sql {{ #SOURCE.id }};;
}
//highlight-start
dimension email {
label: 'Email'
type: 'text'
definition: @sql ${pii};;
}
//highlight-end
// Other dimensions...
table_name: 'ecommerce.users'
}
```
- If the user **doesn't have** PII access, the expression returns a string literal `'(redacted)'`
- If the user **has** access, it returns `{{ #SOURCE.email }}` which references the actual column
### Result
Done. Now when a user explores the `email` field, or views a chart/dashboard that uses this field, the system checks their PII access and shows the customer email only when they have been granted permission.
This way:
- The dimension's SQL definition is dynamically generated based on the user's `pii_access` attribute
- For users without PII access, the SQL resolves to just `'(redacted)'`
- For users with PII access, the SQL resolves to `{{ #SOURCE.email }}` which fetches the actual column value
- This approach works for all users, including those with Dataset access
---
## Custom access request page
:::warning Custom Plans
This feature is available in our custom plans. Please contact us to get access.
:::
## Introduction
Ever had users message you frantically because they can't access a dashboard? With custom 403 pages, you can turn that dead-end "Access Denied" screen into something actually useful.
Instead of users hitting a wall, you can:
- **Send them to a request form** - Link to Google Forms, Jotform, whatever you use
- **Show who to contact** - "Need the sales dashboard? Email sales-ops@company.com"
- **Give them next steps** - Link to your wiki, ticket system, or training docs
- **Pre-fill their info** - Their name, email, and the dashboard they need are already filled in
## Common use cases
### The "request access" button
This is what most teams use. User can't see a dashboard? They click a button, fill out a form, done. You get notified and can approve it.
### "Here's how to get access"
Some companies have specific processes - maybe you need to complete data training first, or submit a ticket through ServiceNow. Link directly to those resources so users can help themselves.
## Quick setup
1. Go to **Organization Settings** → **Custom 403 Page**
2. Grab one of the templates below (or write your own)
3. Replace the example links with your actual form URLs
4. Hit save
That's it. Next time someone hits a restricted dashboard, they'll see your custom message instead of a generic error.
## Ready-to-use templates
### Template 1: "request access" button
```html
403 - Access Forbidden
403 - Access Forbidden
You don't have permission to access this resource. Please request access to continue.
Request Details
User:
{{USER_NAME}}
Email:
{{USER_EMAIL}}
Company:
{{TENANT_NAME}}
Path:
{{CURRENT_PATH}}
Host:
{{CURRENT_HOST}}
Back to Dashboard
Request Access
```

Remember to replace the link in the Request Access button with the link to your form
```html
Request Access
```
### Template 2: connecting to form services
Want to pre-fill the user's info in your form? Here's how to set that up with different services:
#### Google Forms
```html
Request via Google Forms
```
#### Jotform
```html
Request via Jotform
```
#### Microsoft Forms
```html
Request via Microsoft Forms
```
#### Typeform
```html
Request via Typeform
```
## Variables you can use
These automatically fill in with the user's actual info:
| Variable | Description | Example Output |
|----------|-------------|----------------|
| `{{ USER_NAME }}` | User's display name | "Jane Smith" |
| `{{ USER_EMAIL }}` | User's email | "jane@company.com" |
| `{{ TENANT_NAME }}` | Your organization | "Acme Corp" |
| `{{ CURRENT_PATH }}` | Dashboard path | "/dashboards/sales-metrics" |
| `{{ CURRENT_URL }}` | Full URL attempted | "https://app.holistics.io/dashboards/123" |
| `{{ CURRENT_HOST }}` | Domain name | "app.holistics.io" |
### Heads up: URL encoding
Some form services get confused by special characters in URLs. If your form isn't pre-filling correctly, try URL encoding:
```html
Request Access
```
## What works (and what doesn't)
### You can use:
- HTML and CSS (inline or in `
Tool
Description
Reporting
Development
list_datasets
List available datasets (unames, labels, descriptions, tags).
✓
✓
fetch_dataset
Fetch full details of a dataset: dimensions, measures, models, relationships, and analytics contexts.
✓
✓
explore_dataset
Explore a dataset interactively.
✓
✓
fetch_sample_data
Fetch sample values of a model dimension to understand its data shape.
✓
✓
lookup_values
Look up exact values of a model dimension for use in AQL filters.
✓
✓
generate_aql
Generate an AQL query for a dataset from a natural language question.
✓
✓
validate_aql
Validate an AQL query and return any syntax or semantic errors.
✓
✓
execute_aql
Execute an AQL query and return the result data.
✓
✓
explain_result
Explain or surface insights from a query result.
✓
✓
generate_viz
Generate an AML Viz definition from a natural language description.
✓
✓
execute_viz
Execute an AML Viz and return the visualized result.
✓
✓
search_dashboards
Search dashboards by query (returns unames, titles, descriptions, tags).
✓
—
search_viz_blocks
Search viz blocks by query (returns label, description, tags, AML Viz, AQL).
✓
—
execute_viz_block
Execute a viz block.
✓
✓
list_data_sources
List all data sources with their name and database type.
✓
✓
list_data_source_schemas
List all schema names in a data source.
✓
✓
read_data_source_schema
List all tables within a schema.
✓
✓
read_data_source_table_schema
Read a table's columns, types, and constraints (PKs, FKs, unique).
✓
✓
search_docs
Search Holistics documentation (AML, AQL, datasets, visualizations, etc.).
✓
✓
list_skills
List available automatic skills with their IDs and descriptions.
✓
✓
load_skill
Load the full content of a skill by ID.
✓
✓
fetch_org_context
Fetch AI context and instructions set by your organization.
✓
✓
fetch_context
Fetch a specific context chunk by ID.
✓
✓
---
## Run AI Functions in Your Data Warehouse
:::warning Platform Availability
This feature is **only available on Databricks and Snowflake** data platforms. These functions leverage the native AI capabilities provided by these platforms and are not supported on other database systems.
:::
## Introduction
If your data warehouse includes **SQL functions for generative AI models**, you can call those AI functions directly in Holistics.
When you perform an AI query, you call an AI model from your cloud data warehouse and run it on your database's columns, returning the output to Holistics. The AI processing happens entirely within your secure data warehouse environment, ensuring data privacy and compliance.
## Prerequisites
To use AI functions in Holistics, you'll need a connection to a supported data warehouse with AI capabilities enabled. Currently, this feature works with **Databricks** (using their AI Functions) and **Snowflake** (using Cortex functions).
Your database user will need the appropriate permissions to execute AI functions in your warehouse. This typically means having access to run the AI-specific SQL functions like `ai_complete()` in Snowflake or `ai_query()` in Databricks. If you're unsure about your permissions, try running a simple AI query directly in your warehouse first.
## Available AI functions
These are the AI functions available in Holistics's AQL. Behind the scene, Holistics translate these into relevant SQL functions provided by the data warehouses.
| Function | Description | Example Usage | Return Type |
|----------|-------------|---------------|-------------|
| **[ai_complete()](/reference/aql/ai-functions#ai_complete)** | Query an AI model with custom prompts | `ai_complete('gpt-4', concat('Summarize this review: ', comment))` | Text |
| **[ai_classify()](/reference/aql/ai-functions#ai_classify)** | Classify text into predefined categories | `ai_classify(ticket_text, 'bug', 'feature', 'question')` | Text (category) |
| **[ai_similarity()](/reference/aql/ai-functions#ai_similarity)** | Calculate semantic similarity between texts | `ai_similarity(product_desc1, product_desc2)` | Number (0-1) |
| **[ai_summarize()](/reference/aql/ai-functions#ai_summarize)** | Generate concise summaries of long text | `ai_summarize(article_content)` | Text |
For detailed syntax and more examples, see the [AI Functions Reference](/reference/aql/ai-functions).
## How it works & examples
AI functions excel at three main tasks:
- **Text Analysis**: Classify sentiment, extract themes, summarize content
- **Data Enrichment**: Auto-categorize items, generate descriptions, extract attributes
- **Similarity Matching**: Find duplicates, build recommendations, detect patterns
### Your first AI query
Let's start with a simple sentiment analysis on customer reviews:
```aml
// Analyze sentiment of customer feedback
dimension feedback_sentiment {
model: reviews
type: "text"
definition: @aql
ai_classify(reviews.comment, 'positive', 'negative', 'neutral')
;;
}
```
This will classify each review as positive, negative, or neutral using AI.
### Customer Feedback Analysis
Let's look at an example that analyzes customer reviews to understand sentiment.
```aml
// 1. Define the review model with AI enhancements
Model enhanced_reviews {
type: 'table'
table_name: 'customer_reviews'
dimension review_text {
type: 'text'
definition: 'review_text'
}
dimension sentiment {
type: 'text'
definition: @aql
// highlight-next-line
ai_classify(review_text, 'positive', 'negative', 'neutral')
;;
}
dimension sentiment_score {
type: 'number'
definition: @aql
case(
when: sentiment == 'positive', then: 1,
when: sentiment == 'neutral', then: 0,
when: sentiment == 'negative', then: -1
)
;;
}
dimension review_summary {
type: 'text'
definition: @aql
// highlight-next-line
ai_summarize(review_text)
;;
}
dimension mentioned_features {
type: 'text'
definition: @aql
// highlight-start
ai_complete(
'gpt-4',
concat('List product features mentioned in this review (comma-separated): ', review_text)
)
// highlight-end
;;
}
metric avg_sentiment_score {
type: 'average'
definition: @aql avg(sentiment_score) ;;
}
metric review_count {
type: 'count'
definition: @aql count(review_id) ;;
}
}
```
### Product Categorization & Enrichment
Auto-categorize products and enhance descriptions with AI.
```aml
Model smart_products {
type: 'table'
table_name: 'products'
dimension name {
type: 'text'
definition: 'product_name'
}
dimension description {
type: 'text'
definition: 'product_description'
}
// Auto-categorize products
dimension ai_category {
type: 'text'
definition: @aql
// highlight-start
ai_classify(
description,
'Electronics & Tech',
'Fashion & Apparel',
'Home & Living',
'Sports & Outdoors',
'Beauty & Health',
'Books & Media',
'Toys & Games',
'Food & Beverages'
)
// highlight-end
;;
}
// Generate SEO description
dimension seo_description {
type: 'text'
definition: @aql
// highlight-start
ai_complete(
'gpt-4',
concat('Write a 50-word SEO-optimized description for: ', name, '. ', description)
)
// highlight-end
;;
}
// Extract key attributes
dimension product_attributes {
type: 'text'
definition: @aql
// highlight-start
ai_complete(
'gpt-4',
concat('Extract key attributes (size, color, material, etc.) as JSON from: ', description)
)
// highlight-end
;;
}
// Find similar products
dimension similarity_to_featured {
type: 'number'
definition: @aql
// highlight-next-line
ai_similarity(description, 'some featured product description')
;;
}
}
```
### Support Ticket Intelligence
Automatically classify, prioritize, and summarize support tickets.
```aml
Model intelligent_tickets {
type: 'table'
table_name: 'support_tickets'
dimension ticket_content {
type: 'text'
definition: 'ticket_description'
}
// Classify ticket type
dimension ticket_type {
type: 'text'
definition: @aql
// highlight-start
ai_classify(
ticket_content,
'bug',
'feature_request',
'how_to_question',
'account_issue',
'performance_problem'
)
// highlight-end
;;
}
// Determine urgency
dimension urgency_level {
type: 'text'
definition: @aql
// highlight-start
ai_classify(
ticket_content,
'critical - system down',
'high - major issue',
'medium - workflow blocked',
'low - minor inconvenience'
)
// highlight-end
;;
}
// Generate summary for agents
dimension ticket_summary {
type: 'text'
definition: @aql
// highlight-next-line
ai_summarize(ticket_content)
;;
}
// Extract affected features
dimension affected_features {
type: 'text'
definition: @aql
// highlight-start
ai_complete(
'gpt-4',
concat('List the product features or components mentioned in this ticket: ', ticket_content)
)
// highlight-end
;;
}
// Suggest resolution
dimension suggested_resolution {
type: 'text'
definition: @aql
// highlight-start
ai_complete(
'gpt-4',
concat('Based on this issue, suggest a brief resolution approach: ', ticket_summary)
)
// highlight-end
;;
}
}
```
## Using AI to generate AI queries
Holistics' [Explore Data](/docs/ai/capabilities) AI is also capable of leveraging Data Warehouse AI functions!
### Example 1
> List movies with ratings above 9, at least 800 reviews, recorded in 2024.
For each movie, extract 3 of its best aspects (based on the review). Note: do not simply summarize the review
AI chat demo:
### Example 2
> List movies with at least 5000 reviews, recorded in 2024.
For each movie, identify whether the featured review is positive or negative
AI chat demo:
## Next steps
- Explore the [AI Functions Reference](/reference/aql/ai-functions) for detailed syntax
- Join the [Holistics Community](https://community.holistics.io) to share use cases
- Contact support for help with specific implementations
---
## AI Skills
## What are AI skills?
AI Skills are reusable, packaged instructions that teach your AI agent how to perform a specific task.
Think of them as playbooks the AI can pull off the shelf at the right moment. Instead of writing a long prompt every time to explain how you want a task done, you invoke the skill (or the AI invokes it automatically based on context) and the job gets done consistently.
**Why this matters:**
- **Institutional knowledge as code.** Turn your team's playbooks and domain expertise into reusable assets: not tribal knowledge locked in one person's head.
- **Consistency.** Every team member gets the same methodology, every time.
- **Speed.** Routine tasks that used to take hours become a single prompt.
## Creating AI skills
### Who can create
Admins and analysts.
### Where to create
Go to **Development → Add AI Skill**.
### What goes into a skill
- **Name** (required): Unique identifier for the skill.
- **Label**: Display name shown to users.
- **Description** (required): What the skill does and when to use it. The AI reads this to decide when to activate the skill.
- **Content** (required): Knowledge and instructions the AI follows when the skill is active.
- **Invocation**: How the skill gets triggered:
- **Auto** (default): AI decides when to invoke based on the user's question.
- **Manual**: End users must invoke it explicitly.
- **Allow switching invocation**: Allows end-users to toggle between automatic and manual invocation.
- **User**: Restrict who can access the skill by attribute expression.
**Code example:**
```aml
Skill fin_profit_and_loss {
label: "Profit and loss"
disabled: H.current_user.team != "Finance" //Only Finance team can use
description: '''
Produces a P&L for a given period.
Use when a user asks for a P&L, income statement, or profitability (e.g., "show me the P&L for Q2," "how profitable were we last month," "income statement YTD").
'''
content: @md
Pull revenue, COGS, and all expenses from ${finance_dataset}.
Compute gross profit, net profit and their margins.
Present as a Metric Sheet with a monthly time dimension over the last 12 periods.
Show variance against plan and the prior period.
;;
invocation: "auto"
allow_switching_invocation: true
}
```
### Best practices
- **Clearly define the job.** State what the skill does and when it should trigger. Include the phrases users actually say: not just formal terminology.
- **One job = one skill.** If a skill does three things, split it into three. The AI picks the right specific skill faster than it navigates a mega-skill.
- **Write instructions, not essays.** Use imperative voice ("Pull revenue from the semantic model"), give step-by-step guidance, and explain reasoning only where it changes the output.
- **Show the output format explicitly.** Templates beat descriptions. If you want a specific structure, write it out literally.
- **Give 1–2 worked examples.** One input → output pair is worth hundreds of words of explanation.
- **Reference assets.** Point to metrics or other analytic assets in your semantic layer instead of redefining them inline. Duplicated logic drifts over time.
- **Test and iterate.** Try the skill on 3–5 real user phrasings before rolling it out. If it doesn't trigger when it should, refine the description first: that's where triggering is decided.
## Common use cases
Teams use AI Skills to solve a few recurring problems: repeatable work that takes too long, team-specific context the AI keeps missing, and conventions the AI doesn't follow consistently.
### Workflow
A **repeatable analysis your team runs the same way each time**: fixed steps, known output format.
Reach for this when the task is well-defined and the output should look the same every time.
Examples:
- Weekly business review: summarizes how key metrics moved, ready before your Monday meeting.
- Promotion campaign analysis: the standard readout you run after every promo.
- Anomaly investigation: walks through segments to find what caused a spike or drop.
### Team library
A **curated set of skills built for one team**: its workflows, vocabulary, datasets, and reporting conventions. Scope skills to a team so only the right people see them, and the AI reaches for the right one when someone from that team asks a question.
Reach for this when a team has a recurring set of questions that share the same background knowledge.
Examples:
- Finance: P&L, balance sheet, cash flow, ARR waterfall, finance terms
- Marketing: campaign performance, attribution breakdown, channel mix, marketing terms.
- Product: activation funnel, feature adoption, retention cohorts, product terms.
### Convention
**Rules the AI should follow** whenever it does a certain kind of work. These are the shared foundation other skills build on. usually invoked through chaining, not directly.
Reach for this when you keep correcting the AI the same way across different tasks.
Examples:
- Time period conventions: called whenever a query references quarters, years, or "last period."
- Auto-layout rules: called whenever a dashboard is built.
- Chart formatting standards: called whenever a visualization is produced.
## Using AI skills
Three ways skills get invoked:
- **Automatically:** The AI picks up on what you're asking and activates the right skill. Ask "why did sales drop?" and `anomaly_investigation` kicks in.
- **Chained:** One skill calls another in the background. For example, `dashboard_building` automatically calls `auto_layout` when it's time to arrange the charts.
- **Manually:** Use **slash** (`/`) and pick a skill: e.g., `/fin_profit_and_loss`. Handy when you want explicit control, when more than one skill could apply, or when you'd rather pick than type.
{/*
## Example skills
**Weekly business review**
**Name:** `weekly_business_review`
---
**Description:** Generate a summary of how key metrics changed from last week to this week. Skill triggered when user asks for WBR, weekly update, Monday report, or similar.
---
**Content:**
**Step 1: Determine the comparison window**
- Default: most recently completed ISO week vs. the week before it.
- Override if the user specifies a different window.
**Step 2: Determine which metrics to compare**
Priority order:
1. Metrics the user explicitly names.
2. If user has a pinned or mentioned dashboard, use the metrics extracted from the dashboard's AML.
3. If user has a pinned or mentioned dataset, use the metrics defined in the dataset.
4. Metrics extracted from a dataset that user has used most frequently and recently.
5. If none of the above, ask the user which metrics to include. Always pull existing metrics. Never compute new metrics in this report.
**Step 3: Compute changes**
- Absolute change: this_week - last_week
- % change: (this_week - last_week) / last_week
- If either week has no value, mark N/A and skip styling.
**Step 4: Determine direction**
- If not yet instructed anywhere, use your judgement to determine the good direction and the bad direction.
**Step 5: Display the answer**
- Fill in the template below and display it to user. It's the only thing user will see.
- Do not display any visualizations you searched or created in the process.
📊 Weekly Business Review (use heading 1)
Week from [date] to [date]
💡 Highlights (use heading 2)
Pick up to 3 metrics with the largest % change (either direction). Lead with unfavorable moves.
🔢 Metric table (use heading 2)
A table of: 1) key metrics, 2) last week value, 3) this week value, 4) changes, and 5) changes in percentage.
💪 Recommended actions (use heading 2)
Only include up to 3 actions directly supported by a metric movement in this report. If nothing is clearly actionable thus no recommended actions at all, write "No action recommended this week."
**Example**
User: "Give me this week's WBR for the revenue dashboard"
Output:
**# Weekly Business Review**
Week from 2026-04-13 to 2026-04-19
**## Highlights**
- Revenue grew 6.2% WoW, driven by expansion MRR
- New signups dropped 14%: worst week in Q2
- Gross margin held flat at 78%
**## Metric table**
|** Metric **|** Last week **|** This week **|** Change **|** % **|
|---|---|---|---|---|
| Revenue | \$1.20M | \$1.27M | +\$70K | +6.2% |
| New signups | 420 | 361 | -59 | -14.0% |
| Gross margin | 78% | 78% | 0 | 0% |
**## Recommended actions**
- Investigate the signup drop: check paid channel performance first.
*/}
---
## User Access
Admins can control who can use Holistics AI. This helps you roll out AI gradually and enable it for selected teams first.
By default, **all Admins have access to AI.** This access cannot be removed.
To manage AI access for other users, go to **Organization settings → AI settings → User access.**
---
## Analytics-as-Code
Analytics-as-code is the **durability backbone** behind Holistics' programmable semantic layer ([AML](/reference/aml/)) and composable query language ([AQL](/as-code/aql/)). The semantic layer is what lets AI and humans reason from real business definitions; analytics-as-code is what keeps those definitions durable, evolvable, and useful inside modern engineering workflows.
For the full positioning argument, see [Why Holistics](/docs/difference).
## What "analytics-as-code" means here
Every definition in Holistics is **code, in a Git repository**:
- **Models, datasets, dashboards, relationships**: written in [AML](/reference/aml/), the typed declarative language
- **Metrics**: written in [AQL](/as-code/aql/), composable and reusable
- **Permissions, user attributes, environments**: also defined declaratively in code
You can edit through the GUI or write code directly in [AML Studio](/docs/development/aml-studio). Both write to the same underlying code base. Two-way sync means data teams and analytics engineers don't have to choose one workflow over the other.
## What treating analytics as code unlocks
Many BI tools offer "Git export" or YAML modeling as a developer-experience add-on. In Holistics, code is the substrate, which means several capabilities follow at once:
- **Governance.** Every definition has history, authorship, review, branches, environments, and rollback. The same engineering rigor that protects production code now protects business logic. ([See below](#governance-durable-reviewable-traceable-business-logic).)
- **Composability and reuse.** Many BI tools call themselves "analytics-as-code" but use YAML, which is schemaless, untyped, and forces Jinja-template workarounds for any reuse. [AML](/reference/aml/) is a typed language built for analytics: real types, real abstractions ([modules](/reference/aml/module), [extends](/reference/aml/extend), [partials](/reference/aml/partial), [constants](/reference/aml/constant), [string interpolation](/reference/aml/string-interpolation), [conditionals](/reference/aml/if-else)), and real IDE tooling. See [AML vs YAML](/as-code/amql/aml-vs-yaml) for the full argument.
- **Developer ergonomics.** Type checking at compile time, autocomplete and go-to-definition in [AML Studio](/docs/development/aml-studio), [inspectable compiled SQL](/as-code/aql/), and a [CLI](/docs/cli/) for local development against the editor of your choice.
- **AI extensibility.** AI agents can read, write, and edit structured code; that's far harder against opaque GUI configurations. Holistics AI generates AQL against the same code your team wrote, and external tools can drive the system through the [MCP server](/docs/ai/mcp-server) or [API](/api/).
- **Automation.** [CI/CD validation](/docs/continuous-integration/), [auto-publish on merge](/docs/continuous-integration/auto-publish), programmatic deploys, and [dynamic environments per branch](/docs/continuous-integration/dynamic-dev-prod-environment).
## Governance: durable, reviewable, traceable business logic
Governance is the most load-bearing benefit of analytics-as-code in a BI context, because business definitions drift faster than application code and the consequences of drift are silent. Code-as-substrate makes drift visible and reversible.
| Capability | What it enables |
|---|---|
| **History & authorship** | Every change has an author, a timestamp, and a diff. Disputes about "who changed the revenue definition?" are answered in seconds. |
| **Pull request review** | Wrong logic doesn't get merged silently. The same review discipline that protects production code now protects business definitions. |
| **Branches** | Experiment in isolation. Test a new metric definition on real data without affecting production users. |
| **Environments** | Develop in dev, validate in staging, deploy to prod through a real promotion workflow. |
| **CI/CD validation** | AML compiles. Type-check business logic before merge. Catch breaks before users do. |
| **Inspectable compiled SQL** | AQL compiles deterministically to SQL, and the output is visible. Trust in human-written *and* AI-written queries comes from being able to see exactly what runs. |
| **Rollback** | Reverting a problematic change is one Git operation, not a forensic exercise. |
The cumulative effect: the semantic layer gets **stronger over time** rather than drifting. Business logic improves through review. Metric definitions stay consistent across human and AI consumers. Embedded customer-facing analytics share the same governed substrate as internal dashboards.
## How AI benefits from this
Holistics AI generates [AQL](/as-code/aql/), not raw SQL, against your governed semantic layer. That's only meaningful if the semantic layer is actually governed. Analytics-as-code is what makes "governed" real:
- AI reuses metric definitions you've **reviewed and merged**, not ad-hoc fragments
- AI's queries compile to inspectable SQL you can verify
- When you improve a metric definition through a PR, every AI answer that uses that metric improves with it instantly, without retraining
- Permission checks happen on the same code-defined access controls humans use
See [Why Holistics AI is reliable](/docs/ai/architecture) for the mechanism in detail.
## Where to go from here
### The basics
Get comfortable with the core code workflow: version control, the end-to-end loop, and how dev and prod stay separate.
Branches, history, and rollback for your analytics code base.
Connect, model, develop, and publish in one pass.
How dev and prod separation works when you ship changes.
### Code review
Wrong logic shouldn't merge silently. Bring pull request review to your business definitions, with GitHub or GitLab.
The PR loop in Holistics, from branch to merge.
Wire up pull requests against a GitHub repository.
Wire up merge requests against a GitLab repository.
Search across your analytics code base to find what to review.
### CI/CD
Automate validation and deployment so breaks get caught before users do.
Automated validation and deployment for analytics code.
Validate AML changes inside your own CI pipeline.
Deploy automatically once a change is merged.
### Multi-environment
Run dev, staging, and prod against different warehouses and schemas, with a real promotion workflow.
Point each environment at a different warehouse.
Use a different schema per environment.
Full environment promotion patterns, including per-branch envs.
### Integrations
Fit Holistics into the rest of your stack and your local editor workflow.
Work alongside dbt for upstream transformation.
Work in your editor (or with a coding agent) using the Holistics CLI.
---
## Two-factor authentication (2FA)
## Introduction {#introduction}
Two-factor authentication (2FA) adds an extra layer of security to your Holistics account.
Once enabled, Holistics will request an extra code along with your email and password during the authentication process. This code will be generated by an authenticator app (e.g., Authy, Google Authenticator, Microsoft Authenticator, etc.) installed on your phone, ensuring that only you can access your account.
:::info Note
This doc is about 2FA for [password-based authentication](/docs/authentication#password-based-authentication) only. For SSO and Google 2FA login methods, please visit the respective identity provider to set up.
:::
## 2FA setup (users) {#2fa-setup-for-users}
### Enable 2FA {#enable-2fa}
2FA for password authentication can be turned on by users or [enforced by admins](/docs/authentication/2fa#enforcement).
To set up 2FA, you can go to **My Account > Security Settings > click Enable 2FA**. Once enabled, you will then be required to use 2FA on every password sign-in. Don't forget to save **backup codes** to regain access in case you lose your authentication device.
### Disable 2FA {#disable-2fa}
If 2FA isn’t [enforced for the entire organization](/docs/authentication/2fa#enforcement), users can turn off 2FA themselves. Go to **My Account > Security Settings > click Disable 2FA**.
After the 2FA is disabled, the previous 2FA setup in the authenticator app and the old backup codes will no longer be valid.
If 2FA is enforced or in case you lose access to your account, only admins can disable 2FA for specific users on the User list page. If the organization still requires 2FA, those users have to set up 2FA again on the next sign-in.
### Update 2FA {#update-2fa}
If you want to change your authenticator app or refresh your setup, go to **My Account > Security Settings > click Update 2FA**.
**Note:** After updating 2FA, your previous authenticator setup and old backup codes will no longer be valid. Make sure to save the new backup codes securely.
### 2FA recovery {#2fa-recovery}
#### Backup code {#backup-code}
In case you lose access to your authentication device, use a backup code to temporarily get access to the Holistics. Click “Use a backup code to verify”.
**Note:** The backup code should be used for recovery purposes only. Don’t overuse it as a two-factor authentication.
#### Contact admin {#contact-admin}
If you also lose your backup codes, contact your admin to [disable 2FA](/docs/authentication/2fa#disable-2fa) for you to temporarily log in to Holistics.
#### Contact Holistics support {#holistics-support}
If the admin loses their 2FA and backup codes, and is **the only admin**, they should email Holistics support. Please CC (copy) at least 3 admins or managers in your company. Holistics will verify the information before regaining access to the account.
## 2FA enforcement company-wide (admins) {#enforcement}
Admins can enforce 2FA for password-based authentication across the entire organization.
### Enable 2FA enforcement {#enforcement-set-up}
To enable 2FA enforcement, simply go to **Admin Settings > Security > Enable Enforce Two-factor Authentication for password-based login**.
Once enabled:
- All existing users will be notified via email and an in-app banner, prompting them to set up 2FA. They can postpone the setup for up to 14 days. After that, they will be logged out and required to complete the setup.
- For newly invited users, they are required to set up 2FA during the activation time.
:::info
- If the login mechanism of the organization is set to **Password & Google login**, enforcing password authentication with 2FA forces all users to set it up, regardless of their current login method.
- If the login mechanism is set to **Google Only or SSO Only**, enforcing 2FA for password authentication won’t be available.
:::
### Monitor 2FA enforcement progress {#monitor-enforcement-progress}
The admin can go to the Users list page to monitor the 2FA status of all users in the organization.
## FAQs {#faqs}
### Why am i forced to set up 2FA? {#faq-why-2fa-enforcement}
Your organization admin can turn on [2FA enforcement](/docs/authentication/2fa#enforcement) to deploy password login 2FA for all users in the organization. Once enforced, users will have 14 days to set up 2FA until this is required.
### What should i do if i forget to save backup codes when setting up 2FA? {#faq-forget-saving-backup-code}
Currently, Holistics doesn’t support viewing backup codes after the 2FA setup flow.
If you forget to save them, try to [disable your current 2FA](/docs/authentication/2fa#disable-2fa) and set it up again right after that. Remember to save the backup codes in this setup.
### What should i do if i can't authenticate the 2FA code when trying to sign in? {#faq-cannot-authenticate-totp-code}
Please follow the guidelines in the [2FA recovery](/docs/authentication/2fa#2fa-recovery) section to continue access to Holistics.
### What should i do if i suspect that my account has been compromised? {#faq-compromised-account}
If you think your account has an unauthorized access, please follow these guides:
- Create a [new, strong password](/docs/authentication#password-based-authentication).
- [Disable 2FA](/docs/authentication/2fa#disable-2fa) and set it up again right after that to generate new backup codes and new 2FA.
### Can i change the 2FA setting? {#faq-change-2fa}
Currently, Holistics doesn’t support changing the current 2FA once it is set up. A workaround for this is to [disable 2FA](/docs/authentication/2fa#disable-2fa) and set it up again right after that. This means that:
- You can update the new 2FA to the current authenticator app or switch to a different one.
- The new backup code list will be generated.
### Can the admin turn on 2FA enforcement for Google or SSO login methods? {#faqs-google-and-sso-2fa-enforcement}
No. It can’t be done on Holistics. 2FA enforcement setting on Holistics is just applied for the password authentication method.
For Google or SSO, please go to the respective identity providers to enforce 2FA for those login methods. These resources may come in handy for you:
- [Enforce 2-Step Verification Guide for Google Workspace Admin](https://support.google.com/a/answer/9176657)
- [Enforce Multi-factor Authentication for Okta Admin](https://help.okta.com/en-us/content/topics/security/mfa/mfa-home.htm)
### Can the admin turn on 2FA enforcement by groups or roles? {#faqs-2fa-enforcement-by-groups-or-roles}
No. Currently, Holistics only supports 2FA enforcement for all users in the organization.
### Can the admin turn off 2FA for specific users? {#faqs-turn-off-2fa-for-specfic-users}
Yes. The admin can [disable 2FA](/docs/authentication/2fa#disable-2fa) for specific users for recovery purposes only if they lose access to their authentication devices.
---
## Authentication methods
## Introduction
As an administrator, you can configure multiple sign-in methods for your users. Holistics supports:
- [Password-based Authentication](#password-based-authentication)
- [Google Sign-In](#google-sign-in)
- [Single Sign On (SSO)](#single-sign-on-sso)
You can enable or disable these methods based on your organization's security requirements.
## Login methods
### Password-based authentication
This method allows your users to authenticate using an email-password pair. When enabled, users will see the **Sign in with Email** option on the Sign In page.
**Password Requirements**: To maintain account security, you can enforce these password requirements:
- Minimum 8 characters
- Combination of lowercase and uppercase letters
- At least 1 number
- Cannot contain company name or email address
- Must not be too weak
### Google sign-in
You can enable Google Sign-In to allow your users to authenticate using their Google accounts. When enabled, users will see the **Sign In with Google** option, which opens a pop-up for Google credentials.
### Single sign on (SSO)
:::info Note
This feature is only available in [**Enterprise** plan](https://www.holistics.io/pricing/).
:::
You can implement SSO to provide a secure, streamlined authentication process for your organization. For detailed configuration steps, visit [SSO Authentication](/docs/authentication/sso).
## Configure login methods
You can control which authentication methods are available to your users:
1. Navigate to
settings **Settings** > **General Settings** > **Security** > **Login Mechanism**
2. Select the allowed login methods from the dropdown box.
## Two-factor authentication (2FA)
You can enable 2FA for password-based authentication. For SSO and Google authentication, 2FA settings are managed through their respective identity providers.
For more details, see [Two-factor Authentication](/docs/authentication/2fa).
## FAQs
### Can users have multiple active sessions?
A: Yes, users can maintain multiple active sessions simultaneously without restrictions.
### What is the session timeout policy?
A: For security purposes, sessions automatically timeout after 30 minutes of inactivity. Users will be logged out after this period. Consider informing your users to save their work or refresh their session during extended periods of inactivity to prevent data loss.
---
## SSO integration with Microsoft Entra ID (Azure AD)
Follow these instructions to set up Holistics SAML SSO with Microsoft Entra ID (formerly Azure Active Directory). If you use a different identity provider and need assistance with configuration, please [contact our support team](mailto:support@holistics.io).
:::note
Microsoft renamed **Azure Active Directory (Azure AD)** to **Microsoft Entra ID** in mid-2023. The screenshots and steps below apply whether your tenant still shows Azure AD or Entra ID.
:::
## Add a new enterprise application
1. In **Microsoft Entra ID** (or **Azure Active Directory**), select the **Enterprise applications** option in the left sidebar. Click on add **New > Enterprise Application** to add a new enterprise application.
---
2. Then, click on **Create your own application**. Enter **Holistics** in the "Name" field and click on the **Add** button to add the application.
## Add users and groups
Click on the newly-created **Holistics** application. Then, select **Users and Groups** in the left sidebar. Finally, add your users into this application.
## Configure single sign-on with SAML
In the **Holistics** application overview, click on **Single Sign-on** on the left sidebar. Select **SAML** as the sign-on method.
### Basic SAML configuration
1. Click edit **Edit** in the **Basic SAML Configuration** section.
Fill in **Identifier** and **Reply URL** fields. If you are unsure about these values, depending on your [data center](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on), go to your **SSO Configuration URL** and copy them. **Remember to toggle Authenticate with SSO (SAML) to on first.**
:::info
Depending on your [data center](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on), your **SSO Configurations URL** would be:
- APAC server: https://secure.holistics.io/manage/settings#sso
- EU server: https://eu.holistics.io/manage/settings#sso
- US server: https://us.holistics.io/manage/settings#sso
:::
---
2. Click **Save** to save the configuration.
### Attributes & claims
1. Click **Edit** on the **Attributes & Claims** section. You will need to edit _every line_ in the **Additional claims** section. Click on each line to begin the editing. Remove the namespace section in all the records.
:::caution Change emailaddress attribute name to email
By default, there is an attribute name **emailaddress** in Entra ID (Azure AD). Change this into **email** to conform to Holistics configurations.
:::
---
2. Click **Save**. After editing, the second section should look like this.
### SAML signing certificate
Download **Certificate (Base64)** from the section. Copy its content and paste it into the **Certificate** box of your **SSO Configurations** in **Holistics**.
:::info
Depending on your [data center](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on), your **SSO Configuration URL** would be:
- APAC server: https://secure.holistics.io/manage/settings#sso
- EU server: https://eu.holistics.io/manage/settings#sso
- US server: https://us.holistics.io/manage/settings#sso
:::
### Set up Holistics
Copy the **Login URL** in the Microsoft Entra ID (Azure AD) section and paste it to **Identity Provider Single Sign On URL** in your **SSO Configurations** in **Holistics**.
:::info
Depending on your [data center](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on), your **SSO Configurations URL** would be:
- APAC server: https://secure.holistics.io/manage/settings#sso
- EU server: https://eu.holistics.io/manage/settings#sso
- US server: https://us.holistics.io/manage/settings#sso
:::
### Test single sign-on with Holistics
Click on **Test** in this section to verify that the setup was done correctly. You should be able to login to Holistics.
---
## SAML/SSO authentication
## What is SSO?
**Holistics** provides **Single Sign-On (SSO)** functionality (available in our Custom Plan) to access it through a single authentication source, like [Okta](https://okta.com). This allows admin users to better manage team access and keeps information more secure.
We use **SAML (Security Assertion Markup Language)**, a standard that permits identity managers like Okta to safely pass authorization credentials to service providers like Holistics.
:::info Note
SAML/SSO Authentication is only available for companies on our Custom Plans. [Contact sales to learn more →](https://www.jotform.com/form/222891687756070)
:::
## Enable SSO in Holistics
In order to authenticate your users with SSO/SAML in your organization, you need to **Enable SSO**. Navigate to **General Settings > Single Sign On > Enable Authenticate with SSO (SAML)** and toggle **Enabled** to on.
### Enforce SSO (optional)
In case you want anyone with an email on the domains configured can only use SAML SSO to log in, you can enable `Enforce SSO`. However, please note that by **enforcing SSO/SAML**, your users who have currently **logged in by Email or Google** will be **forced to log out**. Make sure all of the current works have been saved and tested carefully before proceeding.
## Configure SSO integrations with third-party identity provider
- [Setup SSO Integration with Okta](/docs/authentication/sso/okta)
- [Set up SSO integration with Microsoft Entra ID (Azure AD)](/docs/authentication/sso/entra-id)
## Automate user provisioning with SCIM
Once SSO is configured, you can enable SCIM to automatically sync users and groups from your identity provider to Holistics.
- [SCIM Integration](/docs/authentication/sso/scim)
---
## SSO integration with Okta
These are instructions for setting up Holistics SAML SSO with Okta. If you use a different identity provider and need assistance with configuration, please [contact our support team](mailto:support@holistics.io).
You can always follow steps on Okta's website here:
[Overview / Okta Developer](https://developer.okta.com/docs/guides/saml-application-setup/overview/)
## Create a new application integration
- **Platform**: select `Web` from the dropdown.
- **Sign on method**: select `SAML 2.0`.
## Create SAML integration
- **App name**: `Holistics`
- **You can download Holistics logo via [this link](https://cdn.holistics.io/v3/SSO/holistics-logo.png)**
## SAML settings
- **Single sign on URL:** found inside `Assertion consumer service URL`.
- **Audience URI (SP Entity ID)**: found inside `Identifier`.
- **Name ID format:** Identifies the SAML processing rules and constraints for the assertion's subject statement. You could use `Unspecified` since Holistics does not require any specific format.
- **Application username:** Determines the default value for a user's application username. The application username will be used for the assertion's subject statement. You can select `Okta username`.
- **Attribute statements (our recommended mapping):**
- email → user.email
- first_name → user.firstName
- last_name → user.lastName
## Assign users to Holistics
In Okta's `Assignments` tab, you can now assign users to Holistics. At this moment we don't `Automatically Create Accounts on Sign-in` so you need to assign your users manually.
## SAML configuration for Holistics
- Navigate to `General Settings`, and select the `Single Sign On` tab. You should see this:
- After that, you need to fill in the information to complete your setup. The information can be found in Setup Instruction of your Identity Provider
---
## Set up SCIM with Okta
These are instructions for setting up Holistics SCIM provisioning with Okta. If you use a different identity provider and need assistance with configuration, please [contact our support team](mailto:support@holistics.io).
## Before you begin
:::info Prerequisites
- **SAML SSO with Okta configured**: You must have [SAML SSO with Okta](/docs/authentication/sso/okta) set up before enabling SCIM.
- **SCIM enabled in Holistics**: Follow [Step 1](/docs/authentication/sso/scim#step-1-enable-scim-provisioning) and [Step 2](/docs/authentication/sso/scim#step-2-configure-group-to-role-mapping) in the SCIM provisioning guide to enable SCIM and configure group-to-role mapping.
- **SCIM Base URL and API Token ready**: Copy the **SCIM Base URL** and **SCIM API Token** from Holistics. You'll need them to configure Okta.
- **Admin access**: You must be an Administrator in both Holistics and Okta.
:::
:::warning Important
Set up [group-to-role mapping](/docs/authentication/sso/scim#step-2-configure-group-to-role-mapping) (at least for the Admin role) **before** configuring SCIM in Okta. Otherwise, all synced users (including current admins) will default to the Viewer role, which could lock you out.
:::
## Step 1: enable SCIM in Okta
1. In the Okta admin console, open your Holistics application.
2. Go to the **General** tab.
3. Under **Provisioning**, select **SCIM**.
4. Click **Save**.
## Step 2: configure the SCIM connection
1. Go to the **Provisioning** tab of your Holistics app.
2. Under **Settings > Integration**, click **Edit**.
3. Fill in the following fields:
- **SCIM connector base URL**: Enter the **SCIM Base URL** from Holistics.
- **Unique identifier field for users**: Enter `email`.
- **Supported provisioning actions**: Enable the following:
- Push New Users
- Push Profile Updates
- Push Groups
- **Authentication Mode**: Select **HTTP Header**.
4. Under **HTTP Header**, paste the **SCIM API Token** from Holistics into the **Authorization** field.
5. Click **Test Connector Configuration** to verify the connection.
6. Click **Save**.
:::warning
The SCIM API Token is only displayed once when you generate it in Holistics. If you lose it, you'll need to generate a new one, which will invalidate the previous token.
:::
## Step 3: enable provisioning to app
1. In the **Provisioning** tab, go to **Settings > To App**.
2. Click **Edit** and enable the following:
- **Create Users**
- **Update User Attributes**
- **Deactivate Users**
3. Click **Save**.
## Step 4: configure attribute mappings
Still in the **To App** section, scroll down to the attribute mappings. Make sure the following attributes are mapped correctly:
| Okta attribute | Holistics field | Notes |
|----------------|-----------------|-------|
| `email` | Email address | Used as the unique identifier |
| `displayName` | Name | Displayed in Holistics |
## Step 5: assign users to Holistics
Once SCIM is configured, you can start assigning users.
:::tip Best practice: Roll out in phases
Assign a small pilot group first (a handful of users or one test group) and confirm they sync correctly in Holistics with the right roles. Once you are confident, assign the rest of your organization.
:::
1. In Okta, open your Holistics application.
2. Go to the **Assignments** tab.
3. Click **Assign** > **Assign to People** or **Assign to Groups**.
4. Select the users or groups you want to add.
5. Review their profile attributes and click **Save and Go Back**.
6. Click **Done**.
All assigned users will be synced from Okta to Holistics.
To deactivate a user, unassign them from the Holistics app in Okta. They will be deactivated (not deleted) in Holistics.
## Step 6: push groups to Holistics
Groups help you manage permissions at scale. Instead of assigning access to individual users, you can assign it to a group and let SCIM handle the membership.
1. In Okta, open your Holistics application.
2. Go to the **Push Groups** tab.
3. Click **Push Groups** > **Find groups by name**.
4. Search for and select the group you want to push.
5. Click **Save**.
The group and its members will be synced to Holistics.
:::warning
Users in pushed groups must also be assigned to the Holistics app. Pushing a group alone does not assign its members (make sure each user is individually assigned or assigned through a group in the **Assignments** tab).
:::
## Verify the setup
After completing the steps above:
1. Check the **User Management** page in Holistics to confirm that synced users appear with the correct roles.
2. In Okta, go to **Reports > System Log** and filter for provisioning events to verify that sync operations completed successfully.
Provisioning users and groups may take a few moments. If changes don't appear right away, wait a minute and refresh.
## Troubleshooting
### Users aren't syncing to Holistics
1. **Check the API connection**: In Okta, go to **Provisioning > Integration** and click **Test API Credentials**.
2. **Verify the token**: If the test fails, generate a new SCIM API Token in Holistics and update it in Okta.
3. **Check Okta's system log**: Go to **Reports > System Log** and filter for provisioning events to see error details.
4. **Confirm user assignment**: Make sure the user is actually assigned to the Holistics app in Okta.
5. **Check seat limit**: Provisioning fails if adding the user would exceed your Holistics seat limit.
### Groups aren't appearing in Holistics
1. **Check Push Groups status**: In Okta's **Push Groups** tab, verify the group shows as "Active".
2. **Review the system log**: Look for any errors related to group push operations.
3. **Wait for sync**: Group changes can take a few minutes to propagate.
### User profile changes aren't updating
1. **Verify "Update User Attributes" is enabled**: Check Okta's **Provisioning > To App** settings.
2. **Check the attribute mapping**: Ensure the attributes you're changing are included in the attribute mappings.
3. **Trigger a manual sync**: In Okta, you can force a sync by unassigning and reassigning the user.
---
## Auto-provisioning Holistics users with SCIM
## Introduction
SCIM automatically syncs users and groups from your identity provider (Okta, Microsoft Entra ID, etc.) to Holistics. Manage access in one place: add or remove users in your IdP, and Holistics stays in sync.
## Before you begin
:::info Prerequisites
- **SAML SSO configured**: Your organization must have [SAML Single Sign-On](/docs/authentication/sso/) set up before enabling SCIM.
- **Admin access**: You must be [Admin](/docs/admin/user-roles) in both Holistics and your identity provider.
- **Seat availability**: Ensure you have enough seats in your Holistics plan. When syncing, existing users (same email) get linked without using extra seats, but new users consume seats. Provisioning fails if it exceeds your seat limit.
:::
## How SCIM works
SCIM provisioning in Holistics is **one-way only**, changes flow from your identity provider to Holistics, not the other way around. Once enabled, all user and group changes should be made in your identity provider.
### What gets synced
| Feature | Supported | Example |
|---------|-----------|---------|
| Push new users | ✓ | You assign a new user to the Holistics app in your identity provider |
| Update user profiles | ✓ | You update a user's name or email in your identity provider |
| Deactivate users | ✓ | You remove a user from the Holistics app in your identity provider |
| Push groups | ✓ | You add a new group in your identity provider and push it to Holistics |
| Sync group membership | ✓ | You add or remove users in a group in your identity provider |
### Two types of users
Once SCIM is enabled, you'll have two types of [users](/docs/admin/manage-users) in Holistics:
- **Synced users** are automatically created and managed through your identity provider. They cannot be edited directly in Holistics. Any changes (name, email, status) must be made in your IdP. By default, synced users are assigned the Viewer role.
- **Manually-created users** are users you create directly in Holistics. You can still add users manually if needed. If a user with the same email is later synced from your identity provider, they become a synced user.
You can identify the user type by checking the **Source** column in the User Management page.
### Two types of groups
Similarly, [groups](/docs/admin/manage-users#group-management) can be synced or manual:
- **Synced groups** come from your identity provider and cannot be edited in Holistics.
- **Manual groups** are created directly in Holistics and remain fully editable.
## Set up SCIM in Holistics
Follow these steps to enable SCIM provisioning for your organization.
### Step 1: enable SCIM provisioning
1. Navigate to **Settings > Single sign-on**.
2. Toggle **Enable SCIM Provisioning** to on.
3. Once enabled, you'll see:
- **SCIM Base URL**: The endpoint for your identity provider
- **SCIM API Token**: Required by your identity provider to authenticate SCIM requests
- **Group to role mapping**: Configure role assignments for synced users
### Step 2: configure group-to-role mapping
:::warning Important
Configure group-to-role mapping **before** syncing users from your identity provider. Otherwise, all synced users (including current admins) will default to the Viewer role, which could lock you out.
:::
By default, all synced users get the Viewer role (least permissions). To assign different roles based on group membership, map your IdP groups to Holistics roles in the **Group to role mapping** section.
For example:
- `HOLISTICS_ADMIN` → Admin role
- `HOLISTICS_ANALYST` → Analyst role
- `HOLISTICS_EXPLORER` → Explorer role
**Available roles (from least to most privileged):** Viewer < Explorer < Analyst < Admin
:::info Multiple Group Membership
If a user belongs to multiple mapped groups, they receive the **least privileged** role. For example, a user in both `HOLISTICS_ADMIN` and `HOLISTICS_ANALYST` groups will be assigned the Analyst role.
:::
### Step 3: configure your identity provider
Now configure SCIM provisioning in your identity provider using the **SCIM Base URL** and **SCIM API Token** from Holistics. Follow the guide for your identity provider:
:::tip Best practice: Roll out in phases
Start with a small pilot group, confirm provisioning behaves as expected, then expand to your whole organization.
Validating on a few users first lets you confirm that user creation, profile updates, deactivation, and group membership all sync correctly. It also caps the blast radius of any misconfiguration: SCIM syncs automatically and continuously, so changes propagate quickly once you enable it.
:::
:::info Setup Guides
**Okta**: [Set up SCIM with Okta](/docs/authentication/sso/scim-okta)
**Microsoft Entra ID (Azure AD):**
1. In the Azure portal, go to your Holistics enterprise application.
2. Navigate to **Provisioning** and set the mode to **Automatic**.
3. Enter the SCIM Base URL as the Tenant URL and the SCIM API Token as the Secret Token.
4. Test the connection and save.
5. Configure attribute mappings.
6. Under **Settings > Scope**, choose **Sync only assigned users and groups**, then assign a small pilot group to the Holistics enterprise application.
7. Enable provisioning. Once the pilot group syncs successfully, expand the assignment to the rest of your organization.
:::
For other identity providers, refer to your provider's SCIM documentation using the SCIM Base URL and SCIM API Token from Holistics.
## Transitioning existing organizations
If you already have users and groups in Holistics, here's what happens when you enable SCIM:
### Existing users
- They won't be deleted.
- If a user from your IdP has the same email as an existing Holistics user, they become linked.
- Linked users can no longer be edited directly in Holistics.
- Their role will be determined by group-to-role mapping rules. If no mapping applies, they default to Viewer.
:::warning Role Changes
Existing users with Admin or Analyst roles will be downgraded to Viewer if you don't set up group-to-role mapping first. Make sure to configure your mappings before enabling SCIM.
:::
### Existing groups
- They won't be deleted.
- If a group from your IdP has the same name as an existing Holistics group, they become linked.
- Linked groups become read-only in Holistics.
## Disable SCIM provisioning
If you need to stop using SCIM:
1. Go to **Admin Settings > SSO & SCIM Integration**.
2. Toggle **Enable SCIM integration** to off.
3. Previously synced users and groups will remain in Holistics but become editable again.
:::info
Disabling SCIM doesn't delete any users or groups. It only stops the automatic sync from your identity provider.
:::
## Audit logs
You can check the SCIM audit logs page to view provisioning history and troubleshoot any sync issues.
## FAQs
### Can i still add users directly in Holistics after enabling SCIM?
Yes, you can still create manual users. However, we recommend managing all users through your identity provider for consistency.
### What happens if i delete a user in my identity provider?
When you remove or deactivate a user in your identity provider, they will be **deactivated** (not deleted) in Holistics. They won't be able to log in, but their data and activity history are preserved.
### Can i use SCIM without SAML SSO?
No, SAML SSO must be configured before enabling SCIM. This ensures users authenticate through your identity provider rather than with separate Holistics passwords.
### How often does my identity provider sync with Holistics?
Sync frequency depends on your identity provider's configuration. Most providers (like Okta and Entra ID) push changes to Holistics in near real-time, but some may batch updates on a schedule. Check your identity provider's documentation for details.
### What happens when i hit my seat limit and the IdP pushes a new user?
The provisioning request will fail, and the new user will not be created in Holistics. Your identity provider will receive an error response. You'll need to either increase your seat limit or remove existing users before provisioning new ones.
## Limitations
- **User attributes** cannot be synced via SCIM. [User attributes](/docs/admin/user-attributes) must be manually created and managed in Holistics for all users, whether synced or manually created.
- **Roles** cannot be provisioned directly via SCIM, only through group-to-role mapping.
---
## Billing and invoicing
## Billing and invoicing matters
### Where can i manage Holistics and billing?
Billing and invoicing in Holistics is done via our [external Billing Portal](https://billing.holistics.io/). Do note that this is a separate access from your Holistics account and as such has a separate login and password from your Holistics login.
### I can't log in to the billing portal using my Holistics credentials
Since the Billing Portal is independent from the rest of Holistics, **login credentials for Holistics application and the Billing Portal is different**.
To troubleshoot your login issues:
1. Ensure that you have access to the Billing Portal. If you don't, you will need to request access first.
2. If you forget your billing login information, reset your password by clicking on **Forgot Password?** in the Billing Portal page.
### What is a primary billing contact?
Primary billing contacts are the main point of contact for all billing and invoicing purposes. Each company account can only have a single primary billing contact.
By default, **the email address used to sign up for the Holistics trial will be set as the primary billing contact**. The primary billing contact will receive all invoices and will be able to perform certain admin billing functions on the external billing platform such as updating credit card details. Your current primary billing contact will be displayed on the in-app billing page.
Your primary billing contact does not need to be a paid Holistics user.
### How do we change our primary billing contact?
Your **primary billing contact** can send an in-app support ticket with the Subcription/Billing ticket type and request a change in primary billing contact. Please give the the **Full Name** and **email address** of the new primary billing contact. Your primary billing contact does not need to be a paid Holistics user.
If your primary billing contact has changed recently, there will be some delay in updating it in-app. If it has not updated in-app after your next invoice, please submit a support ticket in-app.
If your primary billing contact is not currently an active user of Holistics, they can reach us via the public [Contact Us Form](https://www.holistics.io/contact-us/) and classify it as 'Billing and Commercial Issues'.
### How do i change or update my credit card?
Your primary billing contact can edit your company's credit card from the [external billing portal](https://portal.holistics.io/).
From the **external billing portal** homepage, go to '**My Details**' and click on '**View More**'. You should see your current credit card under '**Billing Details**'. Click on the blue pencil icon
edit to edit the existing credit card.
:::info
If you are unable to see the '**Billing Details**' section you are not your company's primary billing contact.
:::
### Why can't i update the credit card information in billing portal?
**Only the primary billing contact is able to edit/update the credit card information** in the Billing Portal. To troubleshoot this issue, ensure that you have logged in using the primary billing contact credentials.
To change/update the primary billing contact information, head over to: [How do we change our primary billing contact?](#how-do-we-change-our-primary-billing-contact).
### Can i add a secondary credit card?
Your **primary billing contact** can send an in-app support ticket with the Subcription/Billing ticket type and request to add a secondary credit card. A secondary credit card will be charged in the situation where the primary credit card has failed to be charged 4 times.
If your primary billing contact is not currently an active user of Holistics, they can reach us via the public [Contact Us Form](https://www.holistics.io/contact-us/) and classify it as 'Billing and Commercial Issues'.
### Update billing address
Your **primary billing contact** can edit your company's billing details and address from the [external billing portal](https://portal.holistics.io/). This will be reflected in your invoices.
From the **external billing portal** homepage, go to '**My Details**' and click on '**View More**'. You should see your current company details under '**Account Details**'. Click on the blue pencil icon
edit to edit the company billing and shipping address.
:::info
If you are unable to see the '**Billing Address**' section or the blue pencil icon
edit , you are not your company's primary billing contact.
:::
### Update billing entity name or account name
Your **primary billing contact** can send an in-app support ticket with the Subcription/Billing ticket type and request a change in billing entity or account name.
If your primary billing contact is not currently an active user of Holistics, they can reach us via the public [Contact Us Form](https://www.holistics.io/contact-us/) and classify it as 'Billing and Commercial Issues'.
### How do i add more invoice recepients?
Your **primary billing contact** can send an in-app support ticket with the Subcription/Billing ticket type and request to add more recepients to the invoice mailing list. Please provide the **Full Name** and **Email Address** of the additional recepients. They can also request to remove other invoice recepients this way.
If your primary billing contact is not currently an active user of Holistics, they can reach us via the public [Contact Us Form](https://www.holistics.io/contact-us/) and classify it as 'Billing and Commercial Issues'.
### Can i give others access to invoices?
Yes. Your **primary billing contact** can send an in-app support ticket with the Subcription/Billing ticket type and request to add **secondary billing contacts** with external billing portal access. Secondary billing contacts will be able to log into the [external billing portal](https://billing.holistics.io/) at any time to view and download past invoices.
Please remind your secondary billing contacts to check their email inbox for the invite and accept it to enable access to the external billing portal. **Do note that external billing portal login is separate from Holistics' logins, and as such, a billing contact need not be a paid Holistics user.**
If your primary billing contact is not currently an active user of Holistics, they can reach us via the public [Contact Us Form](https://www.holistics.io/contact-us/) and classify it as 'Billing and Commercial Issues'.
---
## Manage your subscription
## Check your subscription usage
### Object usage
1. Go to the [billing page](https://secure.holistics.io/manage/billing) and look under the **Usage Limit** panel on the right hand side of the page.
2. You will see how many objects are included in your plan and how many you are currently using.
3. For a detailed breakdown of how your objects are being counted, click on **Usage Details**
### User usage
1. Go to the [billing page](https://secure.holistics.io/manage/billing) and look under the **Usage Limit** panel on the right hand side of the page.
2. You will see how many users are included in your plan and how many you are currently using.
:::caution
Both **Active** and **Pending users** are counted towards the total users in your subscription plan.
:::
**Deleted or Public users** will not be counted.
Refer to **[User Statuses](/docs/admin/manage-users#understand-the-user-status)** for a detailed explanation of the differences between the user statuses in Holistics.
## Upgrading or downgrading your subscription
### Adding users or objects to your subscription
You can change your add-ons (such as additional objects or users) from within Holistics. On the [billing page within Holistics](https://secure.holistics.io/manage/billing):
1. Scroll down to the add-ons section and toggle the number of additional users and/or objects you would like to purchase
2. Click on 'Proceed to Payment' to complete payment on our external billing portal.
:::caution
The price shown within the Holistics billing page is what you will have to pay monthly inclusive of the base plan price, not just the new add-ons. It will be reflected correctly on the external billing portal before you make payment.
:::
## Switching payment frequency
You can switch between monthly and annual subscriptions directly from the in-app billing page (for applicable plans).
Please keep in mind the following important points:
- **Renewal Date Change**: Switching your subscription will change the renewal date to the current date.
- **Pro-Rated Credits**: The previous subscription will be pro-rated, and the remaining value will be applied as credits toward the new subscription. Learn more about Pro-ration.
For assistance or specific requests, contact your account manager or submit a support ticket in-app under the Subscription/Billing category.
## Pause your subscription
At Holistics, we understand that unforeseen circumstances may arise. Rather than cancelling your subscription, we recommend pausing it temporarily if you anticipate returning in the future. Pausing your subscription allows you to retain your account and data **(up to 180 days)**.
To pause your subscription, follow these simple steps:
1. **Contact Us:** Reach out to our support team via the in-app form to initiate the subscription pause process.
2. **Specify Duration:** Let us know how long you want to pause the subscription for and why. We will try our best to work with you and accommodate your needs.
## Cancel your subscription
To cancel your subscription, follow these steps:
1. Choose the **Billing** tab in your settings or click on **Billing & Usage page** from the top-right drop-down menu.
2. Click on the **Plan Details** tab.
3. Locate and click the **Cancel Subscription** button.
4. Confirm your decision by selecting the **Proceed with Cancellation** button.
5. Provide the reason for the cancellation.
6. Finally, click the **Cancel Subscription** button to complete the process.
:::info Note
If your admins cannot access your plan details on the Billing & Usage page, you may be on a legacy plan without in-app cancellation support. To manage or cancel your plan, please submit a support ticket via the in-app form.
:::
## FAQs
- I can’t find my account’s subscription usage / The usage page is empty
- Your subscription may be a custom plan with us. Please contact our support team at [support@holistics.io](mailto:support@holistics.io) for more details.
- What happens to my data if I cancel/pause my subscription?
- After you unsubscribe, or when your trial has expired, Holistics will retain your data for **180 days**. After that, the data would be removed from the system.
- How will I be billed when I resume my paused subscription?
- When you choose to resume a paused subscription, the pricing plan picks up where it left off. One notable **adjustment** is the **renewal date.** For instance, if your initial renewal date was the 1st of the month, and you pause your subscription with 15 days left, then resume it on the 8th of the month, the renewal date shifts to the 23rd. This adjustment ensures you are billed only for the active days of your subscription.
- I cannot make any changes to my subscription, there is a pending invoice.
- Your subscription cannot be upgraded, downgraded, or cancelled while there are unpaid charges. Please make payment on your outstanding invoices.
---
## Proration of subscription fees
## Holistics fair pricing policy
We believe in being transparent and fair in our pricing. That means that you will only be billed for the active days of your subscription when you upgrade/downgrade your plans.
## Adding/removing users or objects in your subscription
- When adding new users or objects to you plan, the amount billed will be prorated based on the period of subscription between the date of adding to the end of the billing cycle.
- When removing users or objects from your plan, the amount prorated based on the period between date of removal to end of the billing cycle is automatically applied as credits towards the next billing cycle.
### Example: removing users
1. Let's say you are on our annual Standard plan that comes with 20 users, and your date of renewal is 1st of Jan annually. You had chosen to add 10 users (\$12.50/mth each) for an additional \$1500 annually.
2. On 1st July, you decide to remove 5 users from your subscription plan due to changes in your organization and team.
3. The remaining 6 months of subscription for these 5 users will be automatically prorated as credits towards your next bill on 1st Jan of the following year.
4. Credits that will be applied = \$12.50 * 6 months * 5 users = \$375
5. Amount you need to pay in your next bill = \$600 * 12 (Annual Standard Plan) + \$12.50 * 12 * 5 (Additional 5 users) - \$375 (Credits) = \$7575
## Switching payment frequency
Please keep in the mind the following important points:
- **Renewal Date Change**: Switching your subscription will change the renewal date to the current date.
- **Pro-Rated Credits**: The previous subscription will be pro-rated, and the remaining value will be applied as credits toward the new subscription.
### **Example: switching from monthly to annual payments**
1. You are on the monthly Standard plan (\$720/mth) and your original renewal date is the 1st of each month, and you decide on the 15th of the month that you want to switch to an annual payment frequency (\$7200/yr).
2. Your existing monthly plan will be prorated, and the remaining value will be applied as credits towards the new annual plan.
3. Credits that will be applied = No. of Days Left in the Month / 30 * \$720 = 15/30 * \$720 = \$360
4. Amount you need to pay to switch = \$7200 - Credits Applied = \$6840
5. New Renewal Date: The day that you made the switch to the new plan
For assistance or specific requests, contact your account manager or submit a support ticket in-app under the Subscription/Billing category.
## Pausing and resuming your subscription
When you choose to resume a paused subscription, the pricing plan picks up where it was left off. One notable **adjustment** is the **renewal date.**
### **Example:**
1. Your original renewal date was the 1st of the month, and you pause your subscription on the 15th of the month
2. This leaves you with 15 days left on the subscription fees you’ve already paid for.
3. When you resume the subscription on the 8th of the following month for example, the renewal date shifts accordingly to the 23rd of the following month (8th + the number of days remaining on your plan).
This adjustment ensures you are billed only for the active days of your subscription.
---
## Subscribe to a paid plan
To subscribe to a plan, you will have to do it within Holistics.
## Access the billing & usage page
When you've signed in, click on your company name at the top-right hand corner and then click "**Billing & Usage**".
Only users with the "**Admin**" role can see the billing page and perform billing functions such as subscribing to a plan and adjusting add-ons.
If your billing needs to be handled by your accounts team, you can invite them to your Holistics account as an Admin and they will be able to perform billing functions.
## Subscribe to a paid plan
From the [billing page](https://secure.holistics.io/manage/billing):
1. **Select your billing frequency**
Depending on your data center, your subscription will be charged with the corresponding currency:
- APAC - SGD (S$)
- US - USD ($)
- EU - EUR (€)
2. **Select your plan**
You can select between the **Entry** and **Standard** plans, or contact us at billing@holistics.io to discuss an **Enterprise** plan.
- **Entry Plan:** you can purchase 100 reports at a time by purchasing 100 object increments. 1 object = 1 report.
- **Standard Plan:** Reports are free. You can purchase 1 embedded worker at a time by purchasing 100 object increments. 100 objects is equal to 1 [embedded worker](/docs/jobs/queues-and-workers#what-is-a-workerconcurrent-worker).
3. **(Optional) Purchase adds-on**
If your plan is eligible to purchase additional users/objects, you can purchase them here.
4. **Proceed to payment**
Check the GST box if you are registered as or paying via a Singapore entity/address.
Click *Proceed to Payment* to complete the subscription.
This will bring you to our external billing portal where you can key in your company information and enter your credit card information. Your credit card information is stored and processed by **Stripe**, not by **Holistics**.
Once this is complete, your subscription will be activated and you will also receive an email invite to the [**external billing portal**](billing#where-can-i-manage-holistics-and-billing) so you can view and download all past and future invoices.
:::info Note
Please note that you **cannot** login to the external billing portal using your current **Holistics** credentials. You will need to create a new account for this portal.
:::
---
## Business Calculation (Legacy)
:::danger Important Notice
Starting **April 19, 2024**, Business Calculation will no longer be supported for new user signups and will only be available for legacy use cases.
We recommend switching to [AQL Expression](/as-code/aql/), which offers enhanced functionality.
:::
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dataset field types: Dimensions and Measures](fields-calculation)
- [Holistics Expression](/docs/expression)
:::
## What is Business Calculation
Business Calculation is one type of the custom fields in Holistics computed on the result-set from your exploration activities.
When you create a Business Calculation, you are essentially creating a new field (or column) in your current report, the values of which are determined by a calculation that you control. This new field is saved to your report and can be used to create more robust visualizations. But don't worry: your data in the original data warehouse and modeling layer remains untouched.
Business Calculation is based on the mechanism and concept of [Holistics Expression](/docs/expression). Please refer to the doc for more information.
You can use calculated fields for many, many reasons. Some examples might include:
- Calculation Ratio between 2 columns (like Excel)
- Aggregate Data quickly
- Filter result (apply both for dimension and measures)
## Business Calculation is different from other fields
There are some major differences between Business Calculation and Model Field Expression
- **Business Calculation** can be created by anyone who has at least explore permission (especially Explorers), as opposed to Model Field Expression where creators need to have permission to manage the modeling layer and have a thorough understanding of the company's modeling structure
- A **Business Calculation** can only be used inside a specific report where it's created while Model Field Expression can be reused across reports or datasets that contain the field's model.
- **Business Calculations** operate on the result-set of the exploration activities (where relationship among models is not stricly involved) while **Model Field Expressions** is calculated inside its models (and requires `modeling relationships` if it involves fields in different models).
## Create Business Calculation
:::info Note
Please note that in order to use Business Calculation in any datasets or reports, you need to at least have EXPLORE permission in those datasets or reports.
:::
In Exploration View (when you're exploring dataset or report), just simply click on `+Add Field` in Visualization Panel and select +Add Business Calculation
After that, you need to fill in Field Label and the Syntax/Expression to create the Calculation. The syntax of Business Calculation is based on [Holistics Expression](/docs/expression)
## Some notes
Business Calculation currently does not support cross model calculation unless fields used in the syntax are aggregated
For example:
- We support `sum(model1.field_a) + sum(model2.field_b)`
- But DO NOT support `sum(model1.field_a + model2.field_b)`
---
## Build a Dashboard with Multiple Similar Charts
## Context
Let’s say you need to build a dashboard with 5 pie charts with the following requirements:
- **Country-specific pie charts**. The charts are all pie charts. Each chart shows data grouped by a specific country.
- **Horizontally aligned pie charts**. Charts must be arranged horizontally in a row.
- **Different pie charts use different metrics**. Some charts should display the number of total orders, other charts display the number of total orders received.

This documentation will show you how to build this dashboard in a reusable and maintainable way.
## Build a chart once and use it everywhere
**Requirements:** Country-specific pie charts are pie charts. Each chart shows data grouped by a specific country.
If you duplicate code for each chart, you'd have to update all duplicated code when there are changes. Instead, a more reusable way is to define a pie chart once and use it everywhere. You can use `AML Function` to parameterize a pie chart to accept a country parameter.
```tsx
// Define a function that takes in a string parameter `country`
Func get_order_pie_chart(country: String) {
VizBlock {
// Use `country` variable in the viz label
label: '# of Orders in ${country}'
viz: PieChart {
filter {
field: r(ecommerce_countries.name)
operator: 'is'
// Use `country` variable in the viz filter
value: '${country}'
}
// ...
}
}
}
Dashboard demo_func_ecommerce {
title: 'Ecommerce dashboard'
block v9: get_order_pie('Vietnam')
// Other charts that use different country
block v10: get_order_pie('Germany')
block v11: get_order_pie('France')
block v12: get_order_pie('India')
block v13: get_order_pie('Singapore')
// ...
}
```
## Auto-arrange charts horizontally
**Requirements:** Charts must be arranged horizontally in a row.
If you drag and drop charts manually, you'd spend quite some time obsessing over pixels to make sure they're perfectly aligned. A more automatic way is to calculate the arrangement automatically. You can use a combination of [AML Constant](/reference/aml/constant) and [AML Function](/reference/aml/func) to compute positions given some parameters such as width, height, padding, the number of charts.
```tsx
// Define the width and height of your Canvas Dashboard
const width = 2000
const height = 1000
// y-coordinate of our pie charts
const heightPos = 150
const heightPie = 400
// Declare the number of pie charts to automatically calculate the width of each chart
const numOfPie = 5
const widthPie = (width - padding * numOfPie) / numOfPie
// Define the margin for each pie chart
const margin = 50
const padding = 50
// Define a function to calculate the positions for each visualization block based on their indices and information defined above
Func get_pos(index: Number) {
pos(
margin + widthPie * index + padding,
heightPos,
widthPie,
heightPie
)
}
// Other code ...
Dashboard demo_func_ecommerce {
title: 'Ecommerce dashboard'
view: CanvasLayout {
label: 'View'
// Use variables instead of hard-coded sizes
width: width
height: height
// Call the same function with different indexes to automagically calculate the right positions
block v9 { position: get_pos(0)}
block v10 { position: get_pos(1)}
block v11 { position: get_pos(2)}
block v12 { position: get_pos(3)}
block v13 { position: get_pos(4)}
}
block v9: get_order_pie('Vietnam')
block v10: get_order_pie('Germany')
block v11: get_order_pie('France')
block v12: get_order_pie('India')
block v13: get_order_pie('Singapore')
// Other code ...
}
```
You can change the variables to change the arrangement of charts automatically.
## Reuse a visualization block with different metrics
**Requirements:**. Some charts should display the number of total orders, other charts display the number of total orders received.
You can refactor the `AML Function` that produces a pie chart to accept another parameter that represents the data that the chart will use.
```tsx
const total_orders_count = r(ecommerce_orders.total_orders_count)
const total_orders_delivered_count = r(ecommerce_orders.delivered_orders_count)
// This function is refactored to take in more parameters
Func get_order_pie(metric: FieldRef, country: String) {
VizBlock {
label: '# of Orders in ${country}'
viz: PieChart {
series {
field {
label: ' '
// Use the variable here instead of hard-coding
ref: metric
}
}
// Other code ...
}
}
}
Dashboard demo_func_ecommerce {
title: 'eCommerce func demo'
// Call the function multiple times and pass different metrics as needed
block v9: get_order_pie(total_orders_delivered_count, 'Vietnam')
block v10: get_order_pie(total_orders_count, 'Germany')
block v11: get_order_pie(total_orders_delivered_count, 'France')
block v12: get_order_pie(total_orders_count, 'India')
block v13: get_order_pie(total_orders_delivered_count, 'Singapore'
// ...
}
```
If your charts use AQL expressions for custom calculations, you can still reuse those as followed:
```tsx
Func kpiWidget(fieldRef: FieldRef, offset: Number) {
VizBlock {
label: 'KPI '
viz: MetricKpi {
dataset: aql_dataset
// ...
calculation calc {
label: 'Value'
formula: @aql ${fieldRef.model}.${fieldRef.field}
| relative_period(orders.created_at, interval(-${offset} month)) ;;
calc_type: 'measure'
data_type: 'number'
}
}
}
}
Dashboard demo_func_ecommerce {
block v9: kpiWidget(r(ecommerce_orders.total_orders_count), 2)
block v10: kpiWidget(r(ecommerce_users.total_users_count), 2)
block v11: kpiWidget(r(ecommerce_revenue.total_revenue), 2)
// Other code ...
}
```
---
## Development workflows
### High-level Workflow
Unlike Dashboard 3.0, Canvas Dashboard is created and stored as code. Developing Canvas Dashboard goes through the same process of creating Data Models and Datasets. For more information, you can refer to [Quickstart](/docs/quickstart).
Create & Edit dashboards can be done in two main ways: either **directly in production (coming soon)** or **in the Development workspace**. This documentation will guide you through both methods, helping you decide which one fits your situation best.
## 1. Developing in Reporting
If your changes are minor and only affect a single dashboard, editing directly in production can be the quickest way to get them live.
**Use this method when:**
- Your changes are minor and affect only one dashboard.
- You’re confident with your changes and want them available immediately.
- You wish to avoid dealing with the git workflow: your changes are presented as a commit in master.
**Step 1: Navigate to the dashboard you wish to update**
**Step 2: Click the Edit button**
**Step 3: Edit your dashboard right on Reporting**
:::tip Development in Reporting
Please refer to the [Development in Reporting ](/docs/canvas-dashboard/create-edit-canvas-dashboard/in-reporting) document for more information.
:::
## 2. Developing in Development
For more complex updates involving multiple files or when you need team review, it's recommended to work in the Development workspace.
**When to Use This Method:**
- You need to change multiple files and ensure everything is checked thoroughly before deploying.
- You're unsure about your changes.
- You require review or approval from your team.
### 2.1. Continue working on your current branch
If your current work-in-progress (WIP) changes may affect the dashboard:
**Step 1: Commit Current WIP:** Save your ongoing work.
**Step 2: Pull from Production:**
- Sync your branch with the production environment.
- Resolve conflicts if necessary
**Step 3: Update Dashboard:** Find and update your dashboard.
**Step 4: Complete Your WIP Changes**
- Finish the WIP changes saved earlier to make sure nothings are broken
**Step 5: Deploy All Changes:** Merge and push your updated dashboard along with your WIP Changes.
### 2.2 Create a new branch
By handling features and fixes in separate branches, you can work on multiple updates simultaneously and maintain a clean `main` branch free from questionable code:
**Step 1: Commit Current WIP:** Save your ongoing work.
**Step 2: Create a New Branch From Production:** Start a fresh branch based on the current production state.
**Step 3: Update Your Dashboard:** Find and update your dashboard.
**Step 4: Deploy Your Updates:** Merge and push the updates from the new branch to production.
:::tip Developing in Development
Please refer to the [Developing in Development](/docs/canvas-dashboard/create-edit-canvas-dashboard/in-development) document for more information.
:::
# Conclusion
Choosing the right method for updating your dashboard depends on:
- the complexity of your changes
- whether or not you need team collaboration.
**Direct editing is quick for simple changes while using Development workspace helps manage larger, more uncertain updates efficiently.**
---
## Developing in the Development workspace
For more complex updates involving multiple files or when you need team review, it's recommended to work in the Development workspace.
## Create Canvas Dashboard
To create a Canvas Dashboard, go the the Development tab:
- **Step 1:** Click the “Add (+)” button to add Canvas Dashboard. Alternatively, you can select the “Add” button located under the folder where you want to organize your Canvas Dashboard.
- **Step 2:** Specify the dashboard name and description and click Create.
## Publish Canvas Dashboard to Reporting
Once you’ve developed your Canvas Dashboard, publish it to production to make it available for viewing.
After it has been successfully published, you can view a list of published Canvas Dashboards. Click on the title to view the dashboard in Reporting.
On the Reporting page, you can find the newly created dashboard in the root folder of the Reporting directory. Please note that the folder structures for Reporting and Development are entirely separate.
Alternatively, you can click on the Canvas Dashboard filename, and click on View in Reporting to navigate to the Canvas Dashboard location in the Reporting layer.
---
## Developing in Reporting
If your changes are minor and only affect a single dashboard, editing directly in production can be the quickest way
to get them live.
You can enable this option by navigating to **"Development"** tab, go to **"Settings"** and enable **"Allow direct Dashboard Editing in Reporting"**
:::info Note about Git
Holistics will push the new changes directly to the `master` branch, please ensure the Git account that are connected in Holistics has such permissions
:::
## Create Canvas Dashboard
- **Step 1:** Click the **(+)** button when hovering on the folder title to **Add Canvas Dashboard**
- **Step 2:** Specify the dashboard name and description and click Create.
## Edit Canvas Dashboard
To edit a Canvas Dashboard:
- **Step 1:** Click the **Edit** button on the Dashboard's header
- **Step 2:** Edit your dashboard
- **Step 3:** Click the **Save** button to publish your changes
---
## Building a Reusable Library of Dashboard Blocks
This guide demonstrates how to transform your existing dashboard charts into reusable components for your organization's Block Library. By creating a well-structured library of blocks, you empower dashboard builders to easily discover, implement, and customize sophisticated visualizations with minimal effort.
## Why Create a Block Library?
Your Block Library will:
- Provide consistent visualization patterns across your organization.
- Reduce duplication of effort in dashboard creation.
- Enable less technical users to create sophisticated dashboards.
- Establish governance over analytical presentation standards.
Dashboard builders can now:
- Browse the Block Library, filter by category, and search for relevant blocks.
- Add your blocks to their dashboards with a few clicks.
- Customize parameters through an intuitive interface.
- Create professional visualizations without writing complex code.
Here's what the experience looks like for dashboard builders using your library blocks:
## Add a Block to the Library
VIDEO
The quickest way to create a library block is directly from your canvas dashboard.
### Using the Visual Editor
1. Hover over the block you want to add to the library.
2. Click the **...** menu icon in the top-right corner of the block.
3. Select **Add to Block Library**.
4. Fill in the details:
- **Title** (required): A clear, descriptive name for your block.
- **Description** (optional): Explain what insights this visualization provides.
- **Thumbnail URL** (optional): A custom preview image to help users identify your block.
- **Group** (optional): Categorize your block for easier discovery.
5. Click **Confirm**.
Once added, your block automatically appears in the library and becomes available to all dashboard builders.
### What happens under the hood
When you add a block to the library through the UI, Holistics automatically refactors your chart into a [reusable function](/reference/aml/func) and registers it with the `@template` decorator. The function is created under the `library/blocks` folder in your project:
```
//highlight-start
├── library
│ └── blocks
│ └── mrr_pop_growth.block.aml <-- Your reusable block is created here
//highlight-end
├── settings
├── models
│ └── model_1.model.aml
├── datasets
│ └── dataset_1.dataset.aml
└── dashboards
└── saas.page.aml
```
**Before: Chart logic directly in the dashboard**
```aml title="saas.page.aml"
Dashboard saas {
block v1: VizBlock {
// Chart logic here
}
}
```
**After: Automatically generated when you add to library**
```aml title="mrr_pop_growth.block.aml"
@template(
title = "MRR & PoP Growth", // The title you entered
description = "How does your revenue evolve over time and compare to last year?", // Your description
thumbnail = "https://link.to/image.png" // Your thumbnail URL (if provided)
)
Func mrr_breakdown() {
VizBlock {
// Chart logic moved here
}
}
```
```aml title="saas.page.aml"
Dashboard saas {
// Reference the function in your dashboard
block v1: mrr_breakdown()
}
```
This separation creates a clean boundary between the chart's implementation and its usage. The `@template` decorator transforms an ordinary function into a discoverable, reusable block.
## Create Parameterizable Library Blocks
At this point, your block is ready to use. Other dashboard builders can add it to their dashboards immediately.
However, the block currently uses the same data and settings as the original chart. If you want to **make it more flexible** and allow users to customize datasets, fields, or visual properties, you can add parameters.
### Define parameters
To make your block adaptable for different use cases, replace hardcoded values with parameters. You can use any of the [AML Types](/reference/aml/types) for parameters.
```aml title="mrr_pop_growth.block.aml"
// Before: Hardcoded values
Func mrr_breakdown() {
VizBlock {
// ...
}
}
```
```aml title="mrr_pop_growth.block.aml"
// After: Parameterized for flexibility
Func mrr_breakdown(
dataset: Dataset,
mrr_amount: VizFieldRef,
break_down_dimension: VizFieldRef,
date_field: VizFieldRef,
comparison_period: Number
) {
VizBlock {
// ...
}
}
```
Provide **default values for parameters** to ensure the block works out of the box. When users add it to their dashboards, the block will just work with the default values, without them having to configure anything.
```aml title="mrr_pop_growth.block.aml"
// With default values
Func mrr_breakdown(
dataset: Dataset = saas_dataset,
mrr_amount: VizFieldRef = r(mrr_transactions.mrr_amount), // a metric in a model
break_down_dimension: VizFieldRef = r(mrr_transactions.subscription_type), // a dimension in a model
date: VizFieldRef = r(mrr_transactions.subscription_type), // a dimension in a model
comparison_period: Number = 12
) {
VizBlock {
// ...
}
}
```
When dashboard builders edit a block, they will see the default values you provided. This ensures a smooth experience for users who want to use the block without any configuration.
**Parameter best practices:**
- Provide sensible defaults where appropriate.
- Consider what aspects users will most likely want to customize.
- Balance flexibility with simplicity. Too many parameters can be overwhelming.
### Implement parameter references
Once you've defined parameters, update your function to actually use them. Use [string interpolation](/reference/aml/string-interpolation) to incorporate parameters in AQL field definitions.
```aml title="mrr_pop_growth.block.aml"
Func mrr_breakdown(
dataset: Dataset = saas_dataset,
mrr_amount: VizFieldRef = r(mrr_transactions.mrr_amount),
break_down_dimension: VizFieldRef = r(mrr_transactions.subscription_type),
date: VizFieldRef = r(mrr_transactions.subscription_type),
comparison_period: Number = 12
) {
VizBlock {
viz: CombinationChart {
// Use dataset parameter
//highlight-next-line
dataset: dataset,
calculation metric {
// Use mrr_amount parameter in field formula with string interpolation
//highlight-next-line
formula: @aql sum(${mrr_amount.model}.${mrr_amount.field}),
}
legend: VizFieldFull {
// Use break_down_dimension parameter
//highlight-next-line
ref: break_down_dimension
format {
type: 'text'
}
}
filter {
// Use other parameters
//highlight-next-line
field: date
operator: 'last'
//highlight-next-line
value: comparison_period
modifier: 'month'
}
}
}
}
```
Here are some values you might want to make customizable:
- Dataset names.
- Field references.
- Field formulas.
- Filter values.
- Visual properties (colors, labels, etc.).
## Enhance Block Metadata
Once you've added parameters, your block is fully functional. For larger teams or complex block libraries, you may want to add metadata that helps users discover and understand your blocks more easily.
```aml title="mrr_pop_growth.block.aml"
@template(
title = "MRR & PoP Growth",
description = "Visualize monthly recurring revenue with period-over-period comparison",
thumbnail = "https://link.to/image.png",
metadata = {
// Categorize your block for easier discovery
group: 'SaaS Metrics',
// Specify default dimensions when added to a dashboard
block_width: 620,
block_height: 460,
// Document each parameter
func: TemplateFuncMetadata {
param dataset: GeneralParamMetadata {
description: 'The dataset containing your revenue information'
},
param mrr_amount: GeneralParamMetadata {
description: 'The field containing monthly recurring revenue values'
},
param date_field: GeneralParamMetadata {
description: 'The date field to use for time series analysis'
},
// Useful when a block needs color customization, Holistics will display a color picker
// for dashboard builders so that they don't have to input HEX code
param third_bin_color: StringParamMetadata {
input_type: 'color-picker'
}
}
}
)
```
**Effective metadata includes:**
- **Grouping**: Logical categorization for easier discovery.
- **Size specifications**: Default dimensions when added to a dashboard.
- **Parameter documentation**: Clear descriptions of each parameter's purpose.
- **Input types**: Special input widgets like color pickers for better user experience.
## Best Practices
- **Test your block**. Before publishing to the library, test your block with various parameter combinations to ensure it behaves as expected in all scenarios.
- **Plan for maintenance**. Consider how you'll handle updates to your blocks. Major changes might warrant a new block rather than modifying an existing one that's already in use.
- **Document beyond code**. Maintain comprehensive documentation about your block library, including when to use specific blocks, common customization patterns, and real-world examples.
---
## Embed External Content in Dashboards
Enrich your Holistics dashboards with context and storytelling by embedding external content alongside your reports.
In Holistics, you can embed various external resources, such as Google Slides, Google Forms, FigJam boards, web pages, videos, and more, directly into your dashboard layout.
## How it works
Canvas Dashboard allows you to embed external content directly into your dashboards using [Text Blocks](/docs/canvas-dashboard/text-block) and iframes. To embed external content in your Canvas Dashboard, follow these steps:
* Add a TextBlock to your dashboard.
* Use HTML iframe syntax within the TextBlock to embed the external content.
```html
```
## Troubleshoot
If your embedded content isn't appearing as expected, try the following troubleshooting steps:
* URL format: Ensure that the URL begins with https://, or //.
* Access settings: Check the access settings on the content you're trying to embed. You may need to publish it or make it available outside the source website. In some cases, you might need to set it to public access.
* Authentication requirements: Be aware that some content sources may require users to log in to view the content. This could affect the visibility of the embedded content for different dashboard users.
* Embed code: Check if the content source offers a specific embed code or link intended for sharing. It's often beneficial to use these purpose-built embedding options when available.
* Content source embedding: Consult the content source's documentation for any specific embedding or sharing requirements. Different platforms may have unique procedures or limitations for embedding their content.
---
## Mobile responsiveness in Canvas dashboard
:::tip AML Reference
For the full `CanvasLayout` syntax including mobile parameters, see [CanvasLayout](/reference/aml/canvas-layout).
:::
## Introduction
**Mobile view** (in Canvas dashboard) automatically adjusts the dashboards for different screen sizes across desktop and mobile devices.
## Mobile display modes
Our mobile view follows a single-column structure, stacking blocks vertically. It offers three configuration modes:
* **Auto (Default)** – The mobile layout mirrors the desktop version, arranging blocks left to right, top to bottom. Changes in the desktop layout automatically reflect on mobile.
* **Manual** – Customize the mobile layout independently. You can:
* Rearrange blocks
* Hide specific blocks
* Modify block height using the height or aspect-ratio properties in the code editor.
* **None** – The desktop layout remains unchanged on all devices.
## How to configure the mobile view
You can preview and control the mobile view by enabling **Show mobile display** in Dashboard edit mode:
- **Development**: Support both Visual and Code mode
- **Reporting**: Support Visual mode only
## Syntax
```tsx
Dashboard my_dashboard {
...
view: CanvasLayout {
block v1 {}
block v2 {}
block v3 {}
// highlight-start
mobile: {
mode: "manual"
block v1 {
height: 100
}
block v3
block v2 {
aspect_ratio: "16 / 9"
}
}
// highlight-end
}
}
```
---
## Building Reusable Components in Canvas Dashboard
With Canvas Dashboard, you can define visuals and components once and reuse them across multiple dashboards. This document goes through a simple example to demonstrate the reusability capability.
## Reusable Component Example
### The Setup
Let's say you have a dashboard with the following syntax:
```tsx title="my_canvas_dashboard.page.aml"
Dashboard my_dashboard {
// metadata
title: 'My Canvas Dashboard'
description: 'My new Canvas Dashboard'
// collection of blocks
block t1: TextBlock {
content: @md
Hello world
;;
}
block f1: FilterBlock {
type: 'field'
label: 'User role'
source: FieldFilterSource {
dataset: 'tenant_user'
field: r(public_users.role)
}
}
block v1: VizBlock {
label: 'A pie chart'
viz: PieChart {
dataset: 'tenant_user'
legend: r(public_users.role)
series {
field {
ref: r(public_users.id)
aggregation: 'sum'
}
}
}
}
// layout
view: CanvasLayout {
width: 1200
height: 1200
margin: 10
block f1 {
position: pos(600, 0, 300, 70)
}
block v1 {
position: pos(0, 70, 600, 600)
}
}
}
```
### Reusing Block and Filters within another dashboard
You can refer to your existing viz and filter blocks using the syntax `my_original_dashboard.block.block_name`. For example:
```tsx title="another_dashboard.page.aml"
Dashboard another_dashboard {
// metadata
title: 'Another Canvas Dashboard'
description: 'Another Canvas Dashboard'
// collection of reused blocks from my_dashboard
block t1: my_dashboard.block.t1
block f1: my_dashboard.block.f1
block v1: my_dashboard.block.v1
// layout
view: CanvasLayout {
width: 1200
height: 1200
margin: 10
block f1 {
position: pos(600, 0, 300, 70)
}
block v1 {
position: pos(0, 70, 600, 600)
}
}
}
```
With [AML Extend](/reference/aml/extend), you can also customize the reused block like below
```tsx title="another_dashboard.page.aml"
Dashboard another_dashboard {
// other properties omitted
block v1: my_dashboard.block.v1.extend({
label: "A different label"
})
}
```
### Reusing independent Block and Filters
Alternatively, you can put the viz block and filter in a separate file and reuse them with [AML Constant](/reference/aml/constant) like so:
```tsx title="my_reusable_blocks.aml"
VizBlock myviz {
label: 'A pie chart'
viz: PieChart {
dataset: 'tenant_user'
legend: r(public_users.role)
series {
field {
ref: r(public_users.id)
aggregation: 'sum'
}
}
}
}
FilterBlock myfilter {
type: 'field'
label: 'User role'
source: FieldFilterSource {
dataset: 'tenant_user'
field: r(public_users.role)
}
}
```
Then you can create a new dashboard
```tsx title="my_canvas_dashboard.page.aml"
Dashboard my_reused_dashboard {
title: 'My reused Dashboard'
block f1: myfilter
block v1: myviz
...
}
```
Compared to the previous method, placing the reused blocks in a separate file offers a cleaner structure. However, it can make editing those blocks more challenging as you need to put them in a concrete dashboard to edit them in the UI. Ultimately, the choice between methods depends on your preferences and specific needs.
### Parameterizing components
You can also use [Func](/reference/aml/func) to parameterize your component, like so:
```tsx title="my_functions.aml"
Func myvizBlockWithDataset(dataset: String) {
VizBlock {
label: 'A pie chart'
viz: PieChart {
dataset: dataset
legend: r(public_users.role)
series {
field {
ref: r(public_users.id)
aggregation: 'sum'
}
}
}
}
}
```
and use it like this
```tsx title="my_canvas_dashboard.page.aml"
Dashboard my_reused_dashboard {
...
block v1: myvizBLockWithDataset('tenant_user')
...
}
```
---
## Text Block in Canvas Dashboard
## Introduction
Text blocks allow you to incorporate context and information into your Canvas Dashboard. There are two ways to use them:
- **[Static content](#static-content)**: Add formatted text, links, images, and embedded media using Markdown and HTML.
- **[Dynamic content](#dynamic-content)**: Display live, data-driven content using [Dynamic Content Blocks](/docs/charts/dynamic-content-block).
## Static Content
Text blocks support Markdown, a markup language for formatting text in a plain text editor. You can use markdown to format text, insert links, add images, and embed external content to your dashboard.
For more information on markdown syntax, see [this guide](https://www.markdownguide.org/cheat-sheet/).
### Creating A Static Text/Markdown/HTML Block
To add a text block, click the "Text" button in the dashboard toolbar.
### Using HTML
You can use the `HTML` in the Text block to have Holistics render HTML instead of just showing plain text. Common use cases can be:
- Use HTML to style text with in-line CSS.
- use HTML's `img/video` tag to embed videos and images in your dashboard.
Here is an example code of how to add an image/video to the Holistics dashboard:
```markdown
```
#### Using Markdown inside HTML
Since our markdown engine follows the CommonMark convention if the content has markdown inside an HTML block you need to specify HTML (followed by the start condition and end condition as described [here](https://spec.commonmark.org/0.28/#html-blocks)) to ensure the content renders correctly.
Here is an example of text widget content that has a markdown inside HTML:
```markdown
# Heading level 1
```
### Examples
#### Add Table of Contents
```markdown
# Table of Contents
- [Section 1](#block-v1)
- [Section 2](#block-t1)
- [Section 3](#block-uname)
```
#### Add images
You can add **publicly hosted images** with the following syntax ``.
```markdown

```
#### Add Youtube videos
```markdown
VIDEO
```
#### Add Mermaid diagrams
````markdown
```mermaid
flowchart LR
%% Nodes
A("Project Idea"):::green
B("Initial Planning"):::orange
C("Detailed Design & Requirements"):::blue
D{"Decision: Continue or Stop?"}:::yellow
E("Development Phase"):::pink
F("Testing Phase"):::purple
G("Deployment"):::green
H("Feedback and Improvement"):::orange
%% Edges
A --> B --> C --> D
D -- Continue --> E --> F --> G
D -- Stop --> H
G --> H
H --> B
%% Styling
classDef green fill:#B2DFDB,stroke:#00897B,stroke-width:2px;
classDef orange fill:#FFE0B2,stroke:#FB8C00,stroke-width:2px;
classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px;
classDef yellow fill:#FFF9C4,stroke:#FBC02D,stroke-width:2px;
classDef pink fill:#F8BBD0,stroke:#C2185B,stroke-width:2px;
classDef purple fill:#E1BEE7,stroke:#8E24AA,stroke-width:2px;
```
````
## Dynamic Content
To display live data that updates automatically, use [**Dynamic Content Blocks**](/docs/charts/dynamic-content-block).
Dynamic Content Blocks combine your data with HTML/Markdown templates to create data-driven content such as narrative insights, custom visualizations or dashboard controls.
### Examples
Executive summary with live metrics and conditional formatting:
Product cards grid with images and live data:
For more examples and a step-by-step guide, see [**Dynamic Content Blocks**](/docs/charts/dynamic-content-block).
---
## Area Chart
Area Chart is an extension of Line Chart. It not only displays the series of data points but also gives you a visual representation of volume.
## When to use Area Chart?
An area Chart is used when you want to compare the change of volumes over time **between categories,** or the distribution of categories as parts of a whole. Different area chart types will serve different purposes.
### Standard Area Chart
This is best used to grasp the quantity progression over time.
### Stacked Area Chart
A stacked Area Chart is best when you want to monitor the contribution of categories over time.
### 100% Stacked Area Chart
This is best used to visualize the contribution of categories when you do not care for the actual raw number.
## Create an Area Chart
All settings for the Area Chart are the same as the Line Chart. However, their use is a bit different due to the strong visual impact that Area Chart can bring to your audience.
Start with a series split by a dimension:
Then select Area chart, and map the fields to X-axis, Y-axis and Legend:
:::info
It is possible to add multiple Y-axes to an Area Chart, but it is **not recommended** because your chart will become confusing.
:::
## Styling options
To make full use of the Area Chart, remember to select your desired stacking option.
---
## Bar Chart
Bar Chart presents your data in rectangular horizontal bars with lengths proportional to their values. In other words, it is similar to [Column Chart](column-chart.md) and the only difference is their orientation.
## When to use Bar Chart
A Bar Chart is used to compare measures across **discrete categories.** While a Column Chart is great when there are a few categories, a Bar Chart can be used in cases of high cardinality to create a more eye-pleasing visualization.
## Create a Bar Chart
Creating a Bar Chart is similar to creating a Column Chart. However, it is important to sort the bars by their Y-axis values:
## The Dos and Don'ts
### Do not include too many categories
There is still a limit on how many can be displayed at the same time. When there are too many categories, Holistics' visualization engine only displays **some category labels** to ensure they are readable:
This may cause confusion to your report users, so it is best to use a dimension with lower cardinality or group your categories using a Custom Dimension.
---
## Bubble Chart
Bubble Chart is an extension of [Scatter Chart](scatter-chart.md). It still displays data points on a two-dimensional graph using values from two variables, but it can format the points' **sizes** based on a **third dimension.**
## When to use Bubble Chart?
While a Scatter Chart is useful, sometimes it is not visually interesting enough to catch the attention of your end-users. That is when the Bubble Chart comes in - to make your visualization more striking.
Exploring three dimensions at the same time can lead to interesting insights. For example, Mobiles and T-shirts are on the two extremes of the price range, but the total value they bring is roughly equal.
## Create a Bubble Chart
Creating a Bubble Chart is similar to building a Scatter Chart, with the only difference of the **Z-axis.** This is where you put a third dimension to represent the size of the data point.
Unlike X-axis and Y-axis which discrete variables are acceptable, **Z-axis only makes sense with continuous variables.**
---
## Cohort Retention(Charts)
:::warning
This is a legacy built-in chart. We recommend switching to the **[AQL Cohort Retention](/as-code/aql/cookbook/aql-cohort-retention)** approach for better flexibility and more styling options.
:::
:::info Options to build a retention heatmap in Holistics
We support multiple ways to build a retention heatmap. Here's a quick comparison to help you pick the right one:
| Option | Custom colors | Auto color scale* | Maintenance |
|--------|---------------|-------------------|-------------|
| Built-in, legacy Retention Heatmap *(this page)* | No | Yes | Low -- built-in visualization |
| [Pivot Table](/as-code/aql/cookbook/aql-cohort-retention) with conditional formatting *(recommended)* | Yes | Coming soon | Low -- built-in visualization |
| [Dynamic Content Block](/docs/charts/dynamic-content-blocks/gallery/retention-heatmap) | Yes | Yes | Higher -- you maintain the HTML/CSS yourself |
**(*) Auto color scale**: Color intensity adjusts automatically to the current data range. Without it, a heatmap built for a max of 1,000 will look washed out when a user's data only goes up to 80, or when a time filter reduces the range.
:::
## Introduction
**Cohort Retention** measures how well a business retains its users over time. Two concepts to know:
- **Cohort**: A group of users who share a common starting point, like signup month. For example: users who first purchased in September 2021.
- **Retention**: How many of those users come back. If 100 users sign up but only 20 return the next month, your retention rate is 20%.
The most common way to visualize this is a heatmap. Rows represent cohorts, columns represent time elapsed, and cell colors reflect retention rates, making it easy to spot trends at a glance.
## Create a built-in retention heatmap
### High-level approach
There are **2 main steps** involved in building a cohort retention using Holistics:
1. Transform your raw data (input data) into the required format
2. Visualize the transformed data using Holistics' built-in cohort retention chart
### Step-by-step guide
Let's say we want to build a cohort retention chart for our e-commerce orders.
First, we need a simple raw data table `orders` that contains the following fields:
- `id`: order id
- `user_id`: ID of the user
- `created_at`: when the order is made
Then, we transform it into a table with these columns:
- **Cohort month**: The month a user made their first purchase (e.g., Sep 2019)
- **Cohort size**: Total number of users in that cohort
- **Month number**: How many months have passed since the user's first purchase (0 = first month, 1 = next month, etc.)
- **Number of users**: How many users from that cohort came back in that specific month
Here's a diagram of the transformation process. We need to build 4 query models that feed into each other:
#### Step 1: Define cohorts
Create a [query model](/docs/query-models) `cohort_dfn (user_id, cohort_month)` to group our cohorts based on the month in which they made their first purchase.
```aml
// Cohort defined by first order => Output: user_id, cohort_month
Model cohort_dfn {
type: 'query'
dimension user_id {
type: 'number'
}
dimension cohort_month {
type: 'date'
}
query: @sql
select
{{#o.user_id}},
date_trunc('month', min({{#o.created_at}}))::date as cohort_month
from {{#orders as o}}
group by 1
;;
}
```
#### Step 2: Calculate cohort size
Create a new query model `cohort_size (cohort_month, total_users)` to count how many users are in each cohort.
```aml
// Cohort size defined by number of users in the cohort
// Output: cohort_month, total_users
Model cohort_size {
type: 'query'
dimension cohort_month {
type: 'date'
}
dimension total_users {
type: 'number'
}
query: @sql
select {{#c.cohort_month}}
, count(distinct({{ #c.user_id }})) as total_users
from {{#cohort_dfn as c}}
group by 1
;;
}
```
#### Step 3: Calculate activity by user by month
Create a 3rd query model `retention_by_user_by_month (user_id, month_number)` that indicates if user X has made a purchase in month Y.
`month_number` is an integer denoting the number of months since the user's cohort month. For example:
- User X belongs to cohort Sep 2019
- X makes a repeat purchase on Nov 2019
- There will be a record `(X, 2)`: 2 months between September and November
A sample table would look like:
```
| user | month_number |
| Alex | 0 |
| Alex | 1 |
| Bob | 0 |
| Bob | 2 |
```
```aml
// Months between the user's acquisition date and their order date
// Output: user_id, month_number
Model retention_by_user_by_month {
type: 'query'
dimension user_id {
type: 'number'
}
dimension month_number {
type: 'number'
}
query: @sql
select
{{#o.user_id}},
((date_part('year', {{#o.created_at}}::date) - date_part('year', {{#c.cohort_month}}::date)) * 12 +
(date_part('month', {{#o.created_at}}::date) - date_part('month', {{#c.cohort_month}}::date))) as month_number
from {{#orders as o}}
left join {{#cohort_dfn as c}} on {{#o.user_id}} = {{#c.user_id}}
;;
}
```
#### Step 4: Aggregate into final retention data
From the data in step 3, aggregate them and build the 4th query model `cohort_retention (cohort_month, month_number, num_users)`. This indicates how many users in cohort X make purchases in month number Y.
```aml
// cohort_month, month_number, num_users
Model cohort_retention {
type: 'query'
dimension cohort_month {
type: 'date'
}
dimension month_number {
type: 'text'
definition: @sql concat('Month ', to_char({{ #SOURCE.month_number}}, 'fm00'));;
}
dimension num_users {
type: 'number'
}
query: @sql
select {{#c.cohort_month}}
, {{#r.month_number}}
, count(distinct({{#r.user_id}})) as num_users
from {{#retention_by_user_by_month as r}}
left join {{#cohort_dfn as c}} on {{#r.user_id}} = {{#c.user_id}}
group by 1,2
;;
}
```
#### Step 5: Create the dataset
Create a new [dataset](/docs/datasets) `cohort` that joins `cohort_size` and `cohort_retention` with a 1-n relationship on `cohort_month`. This is required to create the retention heatmap.
```aml
cohort_retention
}
cohort_size
}
Dataset cohort {
models: [
cohort_retention,
cohort_size,
]
relationships: [
rel(rel_expr: cohort_retention.cohort_month > cohort_size.cohort_month, active: true)
]
}
```
#### Final step: Build the chart
Go to Reporting, choose the Retention Heatmap visualization, and drag the fields into each slot.
## Order your Duration column
The variable you use for **Duration** will always be converted into **string type**, and your columns will be in **alphabetical order.**
For example, your Duration variable is "Month name" whose values are:
```
'Jan 2019', 'Feb 2019', 'Mar 2019', 'Apr 2019'...
```
The columns will appear in the alphabetical order, which is not exactly what you want:
```
'Apr 2019', 'Feb 2019', 'Jan 2019', 'Mar 2019'...
```
To have your columns in your desired order, instead of using the month names, you can use the month's numerical representation:
```
'2019-01', '2019-02', '2019-03', '2019-04'...
```
In case your Duration column is of **numeric** type, you will need to create a custom dimension to prepend zeroes to your variable. In other words, you will need to turn your variable from this:
```
1, 2, 3, 4, ..., 10, 11, ...
```
To this:
```
'01', '02', '03', '04', ..., '10', '11', ...
```
---
## Column Chart
Column Chart is one of the most common visualizations. It presents your data in rectangular vertical bars with height proportional to the values of a measure.
## When to use Column Chart?
Column Chart is often used when we need to compare values between **discrete categories**. For example, the number of orders placed by Male and Female customers, or revenue generated by different geographies...
You can also use clustered columns to compare multiple measures at the same time.
## Create a Column Chart
At Visualizations, select Column Chart. Under the Settings tab, drag in the necessary dimensions and measures:
- **X-axis:** Place the field containing the categories you want to compare here.
- **Legend:** Place the field containing the sub-categories that further break down your measures
- **Y-axis:** The measure that you want to compare should go here.
## Clustered Column Charts
### Use Legend to break down a measure
When adding a categorical field into the Legend area, each column will be split into multiple columns corresponding to the categories. Each column still represents the same measure.
### Include multiple measures
If you want to include different measures **of the same scale** (for example Gross Merchandise Value & Net Merchandise Value), you can put them on the same Y-axis:
## The Dos and Don'ts
### Use (Horizontal) Bar Chart if you have too many categories
While the Column Chart is great to compare a few categories, it is not easy to use when there are more. Your users can be distracted when there are too many unordered vertical columns. Column labels are also tilted and hard to read because there is not enough space:
In this case, it makes sense to use the horizontal Bar Chart, and sort the bars in descending order:
### Use low-cardinality dimension in Legend
Too many columns in a cluster will cause distraction and make it hard for chart readers to compare values across dimensions.
For example, you want to compare the average value of orders placed by males and females across different categories:
It makes more sense to put the Gender in Legend field instead of Category:
### Measures in the same cluster should be of the same scale
It may be tempting to put measures of different scales on the same chart to convey as much information as possible. However, we recommend against this practice because it may confuse your users.
For example, here we put the Orders Count and Cancelled Order Ratio together on the same chart. It is confusing to look at a column of raw counts and a column of percentages next to each other.
## Styling options
With Column Chart, you have standard styling options for X-axis, Legend, and Y-axis.
- **X-axis - Title:** set the title of your X-axis
- **Legend:**
- Display label: choose to show or hide the legend
- Alignment: choose the position you want to display your legend
- **Y-axis:**
- **Title:** Set the title of your Y-axis
- **Align:** Choose the position you want to display your Y-axis (left or right of the chart). This is particularly useful when you have multiple Y-axes.
- **Scale:** choose **Linear** to position your data points normally, or choose **Logarithmic** to position your data points at **log(x)**. This is a useful display option when your data has a large difference between min and max values.
- **Min, Max:** By default, this will choose the min and max value of your data, but here you can specify the minimum and maximum limit of your Y-axis
- **Show data label:** show the raw number of data points in your series
- **Stack series:** When a series is split into different groups, this option will stack your groups on one another. You can either display the stack using raw value or use percentage.
- **Display "Total" value in tooltip**
- Totals only work if your measures (in Y-axes) are broken down by Legend;
- Totals are calculated per measure.
- **Group small values into "Others":** This is useful when you have too many groups. It will only show the most prominent ones and gather groups with small values into the "Others" category. You can specify the number of original groups to be displayed.
- **Show rows with no data**: Read more about this option [here](/docs/data-exploration#show-items-with-no-data).
### Stack series
The **Stack series** option is particularly useful for Column Chart. When you have a dimension in the Legend field and choose Stack series, you can have an effective comparison of sub-categories' distribution across another dimension.
For example, here we have a comparison of orders placed by males and females of different age groups:
---
## Combination Chart
Combination Chart allows you to combine different chart types in one visualization. Currently, we support combinations of **Area Chart, Line Chart, Bar Chart, and Column Chart.**
## When to use Combination Chart?
Combination Chart is great when you want to display and distinguish measures of different scales on the same visualization.
## Create a Combination Chart
The basic steps to create a Combination Chart are similar to creating a Column/Bar Chart or Line Chart. However, measures of different scales must be **placed in different Y-axes** and **have different chart types** to clearly distinguish them:
## Styling options
Combination Chart's styling options are similar to other charts. The only difference is that you have the option to choose your measure's char type right in the Settings tab:
---
## Conversion Funnel (Legacy)
>If you signed up after 7th December 2020, please refer to [this document](/docs/charts/conversion-funnel) for the newer version of Conversion Funnel.
**Conversion Funnel** refers to a "journey" that a subject goes through in a particular business context. For example:
- A lead's progress in a sales funnel: lead sourcing → lead qualified with a sales call → follow-ups → lead becomes customers
- A customer's progress in an e-commerce funnel: visits website → browses for products → adds products to cart → purchases → becomes repeated customers
The Conversion Funnel Chart helps you visualize such journeys and the **conversion rates** between the steps.
## When to use Conversion Funnel?
Conversion Funnel is useful when you want to have full visibility of a process and see where to optimize.
## Create a Conversion Funnel
The Conversion Funnel Chart has two components to visualize your funnel: the chart and the table.
The chart will display the overall funnel, while the table shows a detailed breakdown of the funnel over a dimension.
- **X-Axis:** the dimension containing categories that you want your measures to be broken down
- **Legend:** the dimension containing sub-categories that further break down your measures
- **Y-Axis:** This is where you place the measures to quantify the steps in your funnel. In the example above, we used Registered Users, Purchased Customers, and Repeated Customers as measurements of our funnel.
## Styling options
- **Columns color:** Choose the color for your columns
- **Circle color:** Choose the color for the circle containing the overall conversion rate
---
## Conversion Funnel
:::info
Since 7th December 2020, we've applied a redesigned version of Conversion Funnel (see our announcement post here). If you are finding information about the legacy Conversion Funnel, please refer to [this document](/docs/charts/conversion-funnel-legacy).
:::
## Introduction
**Conversion Funnel** refers to a "journey" that a subject goes through in a particular business context. For example:
- A lead's progress in a sales funnel: lead sourcing → lead qualified with a sales call → follow-ups → lead becomes customers
- A customer's progress in an e-commerce funnel: visits website → browses for products → adds products to cart → purchases → becomes repeated customers
The Conversion Funnel Chart helps you visualize such journeys and the **conversion rates** between the steps.
## When to use Conversion Funnel?
Conversion Funnel is useful when you want to have full visibility of a process and identify opportunities to improve a drop between steps in the funnel.
## Create a Conversion Funnel
The Conversion Funnel Chart has two components to visualize your funnel: the chart and the table.
The chart will display the overall funnel, while the table shows a detailed breakdown of the funnel over multiple legends.
- **Breakdown**: help you break your funnel down by one dimension. In our example, we want to see this conversion funnel by country so we dragged the Country field in.
- **Values**: each value field you drag in here will represent a step in the funnel. In our example, we used 4 values to build that funnel: Number of people who visited the page (`Sum of Visited`), Number of people who signed in (`Sum of Signed In`), number of people who viewed listings (`Sum of Viewed Listings`), number of people who booked a hotel (`Sum of Booked Hotel`).
Note that you can change the steps' order by re-arranging the fields' order in visualization settings.
## Styling options
### Table styling
You can customize the appearance of your tables using these styling options:
- **Table color:** Select a color theme that automatically applies different shades to table components (headers, banding, body, font color, etc.).
- **Text and spacing:** Choose from three presets to control text size and cell padding:
- **Compact:** Smaller font size and reduced padding for dense data displays
- **Normal:** Balanced spacing and font size (default setting)
- **Wide:** Larger font size and increased padding for enhanced readability
- **Border styles:** Customize which borders to include or remove in your table.
:::info
Custom table styles applied here will override any table theme styles defined in your [Dashboard theme](/docs/admin/dashboard-themes).
:::
### Chart styling
- **Columns color:** Choose the color for the columns
- **Circle color:** Choose the color for the circle containing the overall conversion rate
---
## AML Custom Chart
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Custom Charts](/docs/charts/custom-charts)
- [Understand Custom Chart](/docs/charts/understand-custom-chart)
:::
Custom charts in Holistics involve two AML objects:
- **`CustomChartDef`** - defines a reusable chart template, stored in a `.chart.aml` file.
- **`CustomChart`** - uses a chart definition inside a `VizBlock` to render data.
## CustomChartDef
`CustomChartDef` declares a reusable custom chart template powered by [Vega](https://vega.github.io/vega/) or [Vega-Lite](https://vega.github.io/vega-lite/). Define it in a `.chart.aml` file under **Development > Library > Custom Charts**.
```aml
CustomChartDef chart_name {
label: String
description: String // optional
icon: String // optional
fields {
field {
label: String // optional
type: 'dimension' | 'measure'
data_type: 'string' | 'number' | 'boolean' | 'date' // optional
sort { // optional
apply_order: Number
direction: 'asc' | 'desc'
}
}
}
options { // optional
option {
label: String // optional
type: 'input' | 'number-input' | 'toggle' | 'radio' | 'select' | 'color-picker'
options: List[String | Number] // required for 'radio' and 'select'
default_value: String | Number | Boolean
}
}
template: @vgl { ... };; // use @vg for Vega
}
```
### Top-level properties
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `chart_name` | identifier | Yes | Unique AML identifier. Must be snake_case (e.g. `my_bar_chart`). |
| `label` | String | Yes | Display name shown in the chart picker. |
| `description` | String | No | Short description shown in the chart picker. |
| `icon` | String | No | Icon shown in the chart picker. |
| `fields` | block | Yes | Defines the data field slots users can drag into. See [`fields`](#fields). |
| `options` | block | No | Defines user-configurable styling controls shown in Visualization Settings. See [`options`](#options). |
| `template` | `@vgl` or `@vg` | Yes | The Vega-Lite or Vega chart specification. See [`template`](#template). |
### fields
Each `field ` block defines one slot that users can drag a dataset field into.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | String | Yes | `'dimension'` or `'measure'`. Fields marked `'measure'` are automatically aggregated. Note: if you reuse Vega-Lite library examples that define aggregation in code, use `'dimension'` for all fields to avoid double-aggregation. |
| `label` | String | No | Display name shown in Visualization Settings. |
| `data_type` | String | No | Restricts accepted field types: `'string'`, `'number'`, `'boolean'`, or `'date'`. |
| `sort.direction` | String | No | Sort direction: `'asc'` or `'desc'`. |
| `sort.apply_order` | Number | No | Sort priority relative to other fields. Lower numbers are sorted first. |
### options
Each `option ` block defines one user-configurable control shown in Visualization Settings.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | String | Yes | Control type. See [option types](#option-types) below. |
| `label` | String | No | Display label shown in Visualization Settings. |
| `options` | List[String \| Number] | Conditional | List of choices. Required when `type` is `'radio'` or `'select'`. |
| `default_value` | String, Number, or Boolean | No | Default value for the option. |
#### Option types
##### `'input'`
Renders a free-text input.
```aml
option my_option {
type: 'input'
default_value: 'Some default value'
}
```
##### `'number-input'`
Similar to `'input'`, but only accepts numeric values.
```aml
option my_option {
type: 'number-input'
default_value: 1
}
```
##### `'toggle'`
Renders a boolean toggle (true/false).
```aml
option my_option {
type: 'toggle'
default_value: false
}
```
##### `'radio'`
Renders a radio button list. Requires `options`.
```aml
option my_option {
type: 'radio'
options: ['option 1', 'option 2', 'option 3']
default_value: 'option 1'
}
```
##### `'select'`
Similar to `'radio'`, but renders a dropdown. Requires `options`.
```aml
option my_option {
type: 'select'
options: ['option 1', 'option 2', 'option 3']
default_value: 'option 1'
}
```
##### `'color-picker'`
Renders a color picker. Resolves to a hex color string.
```aml
option bar_color {
type: 'color-picker'
label: 'Bar color'
default_value: 'cyan'
}
```
Reference the selected color in the template:
```json
"mark": {
"type": "bar",
"color": @{options.bar_color.value}
}
```
### template
The `template` property holds the Vega-Lite (`@vgl`) or Vega (`@vg`) chart specification.
#### Runtime variables
Use `@{...}` placeholders inside the template to access data and user input at runtime.
| Placeholder | Description |
| --- | --- |
| `@{values}` | The dataset rows returned by the query. Always use as `"data": { "values": @{values} }`. |
| `@{fields..name}` | The display name of the field the user dragged into slot ``. |
| `@{fields..type}` | The data type of the field (e.g. `"date"`, `"number"`). |
| `@{fields..format}` | The format string of the field, for use with `holisticsFormat`. |
| `@{options..value}` | The current value of option `` as set by the user. |
For example, if a user drags `Created_at` into `field a` and `Revenue` into `field b`, the runtime data looks like:
```js
{
data: {
values: [
{ a: "01/01/2022", b: 28 },
{ a: "01/02/2022", b: 55 },
]
},
fields: {
a: { name: "Created_at", type: "date", format: "mm/dd/yyyy" },
b: { name: "Sum of Revenue", type: "number", format: "Number (rounded)" }
},
options: {
tooltip: { value: true }
}
}
```
#### Vega-Lite properties
For a full reference, see the [official Vega-Lite documentation](https://vega.github.io/vega-lite/docs/).
##### `data`
In Holistics, the data source must always be specified as:
```json
"data": {
"values": @{values}
}
```
The placeholder `@{values}` is used by Holistics to pass in the data from the dataset fields the user drags in.
##### `mark`
Specifies the shape (bar, line, point, etc.) and its style (color, width, tooltip, etc.).
```json
"mark": {
"type": "bar",
"width": 20,
"tooltip": @{options.tooltip.value},
"color": @{options.bar_color.value}
}
```
##### `encoding`
Maps declared fields to chart dimensions (x, y, color, etc.).
```json
"encoding": {
"x": {
"field": @{fields.a.name},
"type": "temporal"
},
"y": {
"field": @{fields.b.name},
"type": "quantitative"
}
}
```
##### `params`
Declares user interactions the chart accepts (selections, hover, etc.). Used as the foundation for [Cross Filter and Context Menu](#holisticsconfig) via `holisticsConfig`.
```json
"params": [
{ "name": "pointSelection", "select": "point" },
{ "name": "highlight", "select": { "type": "point", "on": "mouseover" } }
]
```
See the [Vega-Lite params documentation](https://vega.github.io/vega-lite/docs/parameter.html) for details.
#### Holistics-specific template properties
##### `holisticsConfig`
It wires Vega-Lite selections (declared in `params`) to Holistics interactive features. See [Make a custom chart interactive](/guides/create-interactive-custom-charts) for a full walkthrough.
| Property | Type | Description |
| --- | --- | --- |
| `crossFilterSignals` | Array of strings | Selection names that trigger [Cross Filter](/docs/cross-filtering) on click. Use a click (point) selection. |
| `contextMenuSignals` | Array of strings | Selection names that open the Context Menu (Date-drill, Drill-through) on right-click. Use a hover selection (`"on": "mouseover"`) so the menu targets the point under the cursor. |
```json
"holisticsConfig": {
"crossFilterSignals": ["myPointSelection"],
"contextMenuSignals": ["myHoverSelection"]
}
```
##### `holisticsFormat`
It applies the user's field format settings to axis labels, legends, tooltips, and headers. Add both properties to the relevant encoding channel:
```json
"axis": {
"format": @{fields.a.format},
"formatType": "holisticsFormat"
}
```
---
## CustomChart
`CustomChart` is used as the `viz` type inside a `VizBlock` to render a custom chart with data.
```aml
block : VizBlock {
viz: CustomChart {
custom_chart: // reference a named definition
dataset:
field : field_ref
// ...
}
}
```
You can also define the chart template inline instead of referencing a named `CustomChartDef`:
```aml
viz: CustomChart {
custom_chart: CustomChartDef {
label: '...'
fields { ... }
template: @vgl { ... };;
}
dataset:
}
```
### Properties
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_chart` | `CustomChartDef` name or inline block | Conditional | The chart definition to use. Required if `custom_chart_id` is not set. |
| `custom_chart_id` | Number | Conditional | ID of a [legacy custom chart](#legacy-custom-charts). Required if `custom_chart` is not set. |
| `dataset` | String or Dataset | Yes | The dataset to query. |
| `field` | VizFieldFull, FieldRef, String, or ConstantVizField | No | Maps a dataset field to a chart field slot. |
| `calculation` | VizCalculation | No | Inline calculated field for use in the visualization. |
| `conditions` | List[String] | No | WHERE-style filter conditions. |
| `having_conditions` | List[String] | No | HAVING-style filter conditions. |
| `filter_groups` | List[FilterGroup] | No | Filter groups for dynamic filtering. |
| `filter` | VizFilter | No | Visualization-level filter. |
| `setting` | Boolean, String, or Number | No | Chart-level setting. |
| `settings.row_limit` | Number | No | Maximum number of rows returned. |
| `settings.aggregate_awareness.enabled` | Boolean | No | Whether aggregate awareness is enabled. |
| `settings.aggregate_awareness.debug_comments` | Boolean | No | Whether to include debug comments in generated SQL. |
| `theme` | VizTheme | No | Visualization-level theme override. See [VizTheme](/reference/aml/theme#viztheme). |
### Legacy custom charts
For legacy custom charts created through **Admin Settings**, reference them by numeric ID. See [Legacy custom chart definition](/docs/charts/understand-custom-chart#legacy-custom-chart-definition).
```aml
viz: CustomChart {
custom_chart_id: 42
dataset: my_dataset
}
```
---
## Custom Charts
:::info Note
- Custom Chart feature is available to all customers in **Standard Plan and above**
- For customers who are eligible for this feature but cannot find it in-app, or customers in **Entry Plan** and would like to give this feature a spin, please let us know via this form: Holistics's Support Ticket
:::
## Introduction
While Holistics's default chart types are often good enough for quick data visualization and analysis, they may not cover many advanced use cases. This is where **Custom Chart** comes in to help you create **complex but reusable chart templates** that cater to your specific needs.
Custom Charts is powered by [**Vega**](https://vega.github.io/vega/) or [**Vega-lite**](https://vega.github.io/vega-lite/) specifications, with Holistics's specifications on top to allow end-users to configure and interact with the charts in the same way as Holistics's built-in charts.
## Getting started
### Step 1: Define a custom chart
Go to **Development > Add > Custom chart**. You can start in three ways:
- **Chat with AI:** Describe the chart you want in plain language and let AI generate the definition
- **Start from template:** Pick from the built-in chart library and customize the result
- **Start from scratch:** Write the AML from an empty template
The result is a `CustomChartDef` block in a `.chart.aml` file in **Development> Library > Custom charts** folder:
```aml
CustomChartDef my_bar_chart {
label: 'My Bar Chart'
description: '...' // optional
fields { ... }
options { ... }
template: @vgl { ... };; // use @vg instead for Vega syntax
}
```
To learn how to write a custom chart definition:
- [Understand Custom Chart](understand-custom-chart)
- [AML Custom Chart Reference](/reference/aml/custom-chart)
- [Video Tutorial: Create a Custom Chart](/guides/create-custom-chart-video)
- [Custom Chart Library](custom-charts/library)
### Step 2: Preview the chart
The chart editor shows your AML code and a live chart preview side by side. Populate the preview with a real dataset to see how the chart renders with actual data.
### Step 3: Build a visualization from the definition
Once defined, the chart definition is available in the chart picker alongside built-in charts. When you add a custom chart to a visualization block, the generated code looks like this:
```aml
block my_block: VizBlock {
viz: CustomChart {
custom_chart: my_bar_chart // reference the custom chart name
dataset: my_dataset
field x_axis: dimension_field
field y_axis: measure_field
}
}
```
## Supported Holistics features
Custom charts support the following Holistics features:
- Interactive features (Explore, Date-drill, Cross-filter, Drill-through, etc.)
- Data Alert and Data schedules for reports
- Export reports to PNG/PDF/Excel/CSV
## Syntax reference
For a full parameter reference for `CustomChartDef` and `CustomChart`, see the [AML Custom Chart Reference](/reference/aml/custom-chart).
:::tip
Hover over `CustomChartDef` or `CustomChart` in the code editor to see suggestions for available parameters.
:::
## Legacy custom chart
Before as-code support, custom charts were non-as-code defined through **Admin Settings > Custom Chart**. For tenants 4.0, existing charts are still usable, viewable, editable, and deletable, but new charts must be created as code.
To migrate a chart:
- Go to **Admin settings > Custom charts**
- Select the chart and click **Copy to Development** to create the as code chart from the legacy chart
- Replace chart references in your dashboards:
- Canvas dashboard: Go to **Development > Code search** to find and replace chart references
- Quick dashboard: Locate and replace the charts manually
- Once all dashboard references are updated, you can safely delete the legacy chart
---
## Custom Chart Library — template store (not published)
Internal storage for the Custom Chart Library templates: one folder per chart with
its AML definition and an SVG thumbnail.
```
_templates/
/
.aml # the CustomChartDef block (mirrors the ```aml block in ../.md)
.svg # 4:3 thumbnail of the chart (Holistics palette, with labels)
```
**This folder is excluded from the docs build.** The Docusaurus content plugin skips
any `_`-prefixed folder (`exclude: ['**/_*/**', ...]` in `docusaurus.config.ts`), so
nothing here is routed, added to the autogenerated sidebar, the chart gallery, or the
published site. It is on-disk storage only.
## Reviewing the thumbnails
```bash
node preview.js # from this folder
# then open http://localhost:4600/preview.html
```
`preview.html` polls a directory-listing endpoint, so adding a new `/.svg`
folder shows up automatically (and edited SVGs refresh) without regenerating anything.
---
## Annotated Time Series
A line chart with labeled event markers, so viewers can connect metric movements to campaigns, releases, and incidents.
- **Good for:** annotating a single metric's trend with releases, campaigns, or incidents; explaining a spike or dip by the event that caused it; sharing a timeline where context matters as much as the numbers.
- **Not great for:** comparing many series at once (use a multi-line or faceted sparkline chart), categorical data with no date axis, or trends that have no notable events to mark.

## Syntax
Use the following AML definition to add the Annotated Time Series to your custom chart library.
```aml
CustomChartDef annotated_time_series {
label: 'Annotated Time Series'
description: 'To plot a metric over time with labeled event markers tied to specific dates.'
fields {
field date {
label: 'Date'
type: 'dimension'
data_type: 'date'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
field event_label {
label: 'Event Label'
type: 'dimension'
sort {
apply_order: 3
direction: 'asc'
}
}
}
options {
option line_color {
type: 'color-picker'
label: 'Line Color'
default_value: '#255dd4'
}
option event_color {
type: 'color-picker'
label: 'Event Marker Color'
default_value: '#e5484d'
}
}
template: @vgl
{
"data": {"values": @{values}},
"layer": [
{
"transform": [
{"aggregate": [{"op": "sum", "field": @{fields.value.name}, "as": @{fields.value.name}}], "groupby": [@{fields.date.name}]}
],
"mark": {"type": "line", "tooltip": true, "color": @{options.line_color.value}},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"x": {
"field": @{fields.date.name},
"type": "temporal"
},
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"axis": {
"format": @{fields.value.format},
"formatType": "holisticsFormat"
}
},
"tooltip": [
{"field": @{fields.date.name}, "type": "temporal", "title": "Date"},
{"field": @{fields.value.name}, "type": "quantitative", "title": "Value", "format": @{fields.value.format}, "formatType": "holisticsFormat"}
]
}
},
{
"transform": [
{"filter": "datum['@{fields.event_label.name}'] != null && datum['@{fields.event_label.name}'] != ''"}
],
"layer": [
{
"mark": {"type": "rule", "strokeDash": [4, 4], "color": @{options.event_color.value}},
"encoding": {
"x": {"field": @{fields.date.name}, "type": "temporal"},
"tooltip": [
{"field": @{fields.event_label.name}, "type": "nominal", "title": "Event"},
{"field": @{fields.date.name}, "type": "temporal", "title": "Date"}
]
}
},
{
"mark": {
"type": "text",
"angle": 270,
"align": "left",
"baseline": "bottom",
"dx": 4,
"color": @{options.event_color.value}
},
"encoding": {
"x": {"field": @{fields.date.name}, "type": "temporal"},
"text": {"field": @{fields.event_label.name}}
}
}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "labelAngle": 0},
"axisY": {"domain": false, "grid": true},
"legend": {"orient": "bottom", "title": null, "labelFontSize": 11, "labelColor": "#858B9E", "symbolStrokeWidth": 0}
}
}
;;
}
```
## Required fields
An Annotated Time Series expects exactly three fields. The template draws the line from `date` and `value`; `event_label` adds the dashed markers and labels.
| Field | Label | Type | Role |
|---------------|-------------|-------------|------|
| `date` | Date | `dimension` | Time axis (x). Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Line height (y). Sorted descending (`apply_order: 2`). |
| `event_label` | Event Label | `dimension` | Event name shown as a vertical marker. Sorted ascending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per date; the template plots `value` directly without summing, so duplicate dates draw a jagged or overlapping line. Populate `event_label` only on the dates that have an event and leave it null or empty otherwise (the template skips rows where it is null or `''` for markers but still draws them on the line).
**Sample data:**
| date | value | event_label |
|------------|-------|----------------|
| 2024-01-01 | 4200 | |
| 2024-01-02 | 4350 | Product launch |
| 2024-01-03 | 5100 | |
| 2024-01-04 | 4980 | |
| 2024-01-05 | 6200 | Press feature |
| 2024-01-06 | 5800 | |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|---------------|-------------|--------|
| `line_color` | `#255dd4` | Color of the metric line. |
| `event_color` | `#e5484d` | Color of the event marker rules and their labels. |
## Known limitations
- **Event markers depend on a sparse label column.** The label field must be null or empty on non-event dates. A value on every row draws a marker on every date and clutters the chart.
- **No second metric.** The template plots one `value` series. Comparing several metrics needs a different chart or a template edit.
- **Dense event labels overlap.** Labels render vertically at each event date, so many events close together collide. Keep events spaced or filter to the notable ones.
---
## Bar Chart with Average Line
A bar chart with an average line overlays a horizontal mean line across a grouped bar chart, so viewers can instantly see which bars sit above or below the overall average.
- **Good for:** spotting over- and under-performers against the series average, comparing categories grouped by a second dimension, benchmark-style comparisons.
- **Not great for:** time-based moving averages (use Bar Chart with Running Average Line), part-to-whole composition, or a single bar with no comparison group.
## Syntax
Use the following AML definition to add the Bar Chart with Average Line to your custom chart library.
```aml
CustomChartDef bar_chart_with_average_line {
label: 'Bar Chart with Average Line'
description: 'To compare bar values against a horizontal average line across the whole series.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field series {
label: 'Series'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
}
template: @vgl {
"data": {"values": @{values}},
"layer": [
{
"mark": {"type": "bar", "tooltip": true},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"x": {
"field": @{fields.dimension.name},
"axis": {"format": @{fields.dimension.format}, "formatType": "holisticsFormat"}
},
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}
},
"xOffset": {"field": @{fields.series.name}},
"color": {
"field": @{fields.series.name},
"legend": {"title": null}
}
}
},
{
"mark": {"type": "rule", "strokeDash": [4, 4], "color": "#E5484D", "size": 2, "tooltip": true},
"encoding": {
"y": {"aggregate": "mean", "field": @{fields.value.name}, "type": "quantitative"},
"tooltip": [{"aggregate": "mean", "field": @{fields.value.name}, "type": "quantitative", "title": "Average", "format": @{fields.value.format}, "formatType": "holisticsFormat"}]
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "labelAngle": -45},
"axisY": {"domain": false, "grid": true},
"legend": {"orient": "bottom", "title": null, "labelFontSize": 11, "labelColor": "#858B9E", "symbolType": "circle", "symbolStrokeWidth": 0},
"bar": {"cornerRadius": 2}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field x {
type: 'dimension'
}
field legend {
type: 'dimension'
}
field y {
type: 'measure'
}
}
options {
}
template: @vgl {
"data": {"values": @{values}},
"layer": [{
"mark": {
"type": "bar",
"tooltip": true
},
"encoding": {
"x": {
"field": @{fields.x.name},
"axis": {
"format": @{fields.x.format},
"formatType": "holisticsFormat",
"labelAngle": 45
}
},
"y": {
"field": @{fields.y.name},
"type": "quantitative",
"axis": {
"format": @{fields.y.format},
"formatType": "holisticsFormat"
}
},
"xOffset": {
"field": @{fields.legend.name}
},
"color": {
"field": @{fields.legend.name}
},
}
}, {
"mark": "rule",
"encoding": {
"y": {
"aggregate": "mean",
"field": @{fields.y.name},
"type": "quantitative"
},
"color": {"value": "firebrick"},
"size": {"value": 3}
}
}],
};;
}
```
## Required fields
A Bar Chart with Average Line expects exactly three fields. Each row is one bar, grouped along the x-axis by `dimension` and split into side-by-side bars by `series`.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | X-axis category; one group of bars per value. Sorted ascending (`apply_order: 1`). |
| `series` | Series | `dimension` | Splits each group into side-by-side bars and drives the color and legend. Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | Bar height. The average line is the mean of this field across all rows. Sorted descending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per `dimension` and `series` pair; the bar layer plots values as-is and does not combine duplicates. The average line is the mean of every plotted `value`, so it reflects all bars in view.
**Sample data:**
| dimension | series | value |
|-----------|---------|-------|
| Jan | Online | 4200 |
| Jan | Retail | 3100 |
| Feb | Online | 3800 |
| Feb | Retail | 3600 |
| Mar | Online | 5100 |
| Mar | Retail | 2900 |
## Known limitations
- **One flat average across the whole series.** The line is a single mean over all plotted rows, not a per-group or per-series average. For a trend that moves period by period, use Bar Chart with Running Average Line instead.
- **Requires a series field.** The definition has three fields, including `series` for the color split. With only one category per x value, leave `series` constant or pick a simple bar chart.
- **Average is sensitive to outliers.** A few very large or very small bars pull the mean line away from the typical value, which can mislead at a glance.
---
## Bar Chart with Running Average Line
A bar chart with a running average line overlays a moving-average line on a time-based bar chart, so you can read each period's value against a smoothed trend in one view.
- **Good for:** smoothing noisy time series, spotting trend direction under volatile bars, comparing each period to its recent neighbors.
- **Not great for:** non-time categories, comparing against a single flat benchmark (use Bar Chart with Average Line), or part-to-whole composition.
## Syntax
Use the following AML definition to add the Bar Chart with Running Average Line to your custom chart library.
```aml
CustomChartDef bar_chart_with_running_average_line {
label: 'Bar Chart with Running Average Line'
description: 'To overlay a running average line on a bar chart and compare each period against a smoothed trend.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
option bar_color {
type: 'color-picker'
label: 'Bar color'
default_value: '#255DD4'
}
option line_color {
type: 'color-picker'
label: 'Line color'
default_value: '#E5484D'
}
option points_before {
type: 'number-input'
label: 'Points before'
default_value: -3
}
option points_after {
type: 'number-input'
label: 'Points after'
default_value: 0
}
}
template: @vgl {
"data": {"values": @{values}},
"transform": [
{
"sort": [{"field": @{fields.dimension.name}}],
"window": [{"op": "average", "field": @{fields.value.name}, "as": "avg"}],
"frame": [@{options.points_before.value}, @{options.points_after.value}]
}
],
"layer": [
{
"mark": {"type": "bar", "tooltip": @{options.tooltip.value}, "color": @{options.bar_color.value}},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"x": {
"field": @{fields.dimension.name},
"type": "temporal",
"axis": {"format": @{fields.dimension.format}, "formatType": "holisticsFormat"}
},
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}
}
}
},
{
"mark": {"type": "line", "tooltip": @{options.tooltip.value}, "color": @{options.line_color.value}, "strokeWidth": 2, "point": {"filled": true, "color": @{options.line_color.value}}},
"encoding": {
"x": {"field": @{fields.dimension.name}, "type": "temporal"},
"y": {"field": "avg", "type": "quantitative"}
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "labelAngle": 0},
"axisY": {"domain": false, "grid": true},
"bar": {"cornerRadius": 2}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field dimension {
type: "dimension"
label: "Dimension"
}
field measure {
type: "measure"
label: "Value"
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
option bar_color {
type: 'color-picker'
label: 'Bar color'
default_value: '#00FFFF'
}
option line_color {
type: 'color-picker'
label: 'Line color'
default_value: '#00FFFF'
}
option points_before {
type: 'number-input'
label: 'Points before'
default_value: -3
}
option points_after {
type: 'number-input'
label: 'Points after'
default_value: 0
}
}
template: @vgl {
"data": {
"values": @{values}
},
"transform": [
{
"sort": [{"field": @{fields.dimension.name}}],
"window": [{"op": "average", "field": @{fields.measure.name}, "as": "avg"}],
"frame": [@{options.points_before.value}, @{options.points_after.value}]
}
],
"layer": [
{"mark": {
"type": "bar",
"tooltip": @{options.tooltip.value},
"color": @{options.bar_color.value}
},
"encoding": {
"x": {
"field": @{fields.dimension.name},
"type": "temporal",
"axis": {
"labelAngle": -45,
"format": @{fields.dimension.format},
"formatType": "holisticsFormat"
}
},
"y": {
"field": @{fields.measure.name},
"type": "quantitative",
"axis": {
"format": @{fields.measure.format},
"formatType": "holisticsFormat"
}
}
}
},
{"mark": {
"type": "line",
"tooltip": @{options.tooltip.value},
"color": @{options.line_color.value}
},
"encoding": {
"x": {"field": @{fields.dimension.name}, "type": "nominal"},
"y": {"field": "avg", "type": "quantitative"}
}
}
]};;
}
```
## Required fields
A Bar Chart with Running Average Line expects exactly two fields. Each row is one bar, ordered along a time axis.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Time axis (plotted as `temporal`); sets bar order and the running-average window. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Bar height and the input to the running average. Sorted descending (`apply_order: 2`). |
**Data requirements:** `dimension` must be a date or datetime, since the x-axis is `temporal`. Pre-aggregate to one row per time period; the template sorts by `dimension` and computes the running average over a sliding window but does not combine duplicate periods.
**Sample data:**
| dimension | value |
|------------|-------|
| 2024-01-01 | 4200 |
| 2024-02-01 | 3800 |
| 2024-03-01 | 5100 |
| 2024-04-01 | 4600 |
| 2024-05-01 | 5300 |
| 2024-06-01 | 4900 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-----------|--------|
| `tooltip` | `true` | Toggles hover tooltips on both the bars and the average line. |
| `bar_color` | `#255DD4` | Fill color of the bars. |
| `line_color` | `#E5484D` | Color of the running average line and its points. |
| `points_before` | `-3` | Window start, in rows before the current point, for the running average. `-3` averages the current period and the three before it. |
| `points_after` | `0` | Window end, in rows after the current point. `0` stops the window at the current period. |
## Known limitations
- **Time axis only.** The x-axis is `temporal`, so `dimension` must be a date or datetime. Non-time categories will not plot correctly.
- **Window is row-based, not calendar-based.** `points_before` and `points_after` count rows, so gaps in the time series (missing periods) skew the average. Fill missing periods first for an even smoothing window.
- **Early bars have a partial window.** The first few points average fewer rows than the full window, so the start of the line is less smoothed than the rest.
---
## Box Plot
A box plot summarizes the distribution of quantitative values by visualizing the median, first, and third quartiles in a single chart. Each box shows the interquartile range, the line inside marks the median, and the whiskers reach out to the rest of the spread.
- **Good for:** comparing distributions across categories, spotting spread and skew, surfacing outliers in a metric.
- **Not great for:** showing a single total or count per category (use a bar chart), trends over time (use a line chart), or part-to-whole composition (use a pie or treemap chart).
## Syntax
Three variants are available, all taking the same two fields:
- **Basic Box Plot**: vertical boxes with a minimal option set.
- **Horizontal Box Plot**: boxes laid out left to right, with color and outlier controls.
- **Vertical Box Plot**: vertical boxes with the same color and outlier controls as the horizontal variant.
### Basic Box Plot

```aml
CustomChartDef box_plot {
label: 'Box Plot'
description: 'To summarize the distribution of a quantitative value across categories by showing the median, quartiles, and range.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
option show_outliers {
type: 'toggle'
label: 'Show outliers'
default_value: false
}
}
template: @vgl {
"data": {
"values": @{values}
},
"layer": [
{
"mark": {
"type": "boxplot",
"extent": 1.5,
"size": 32,
"outliers": @{options.show_outliers.value},
"box": {"cornerRadius": 2},
"median": {"color": "white", "strokeWidth": 2},
"rule": {"color": "#9CA3AF", "size": 1.5},
"ticks": {"color": "#9CA3AF", "size": 10},
"tooltip": @{options.tooltip.value}
},
"encoding": {
"x": {
"field": @{fields.dimension.name},
"type": "nominal"
},
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}
},
"color": {"value": "#255DD4"}
}
},
{
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "fields": [@{fields.dimension.name}], "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "fields": [@{fields.dimension.name}], "on": "mouseover", "clear": "mouseout"}}
],
"mark": {"type": "rule", "opacity": 0, "size": 40},
"encoding": {
"x": {"field": @{fields.dimension.name}, "type": "nominal"}
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "labelAngle": -45},
"axisY": {"domain": false, "grid": true}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field a { // this is to define to holistics the first field input
type: "dimension"
label: "Categorical field"
}
field b { // this is to define to holistics the second field input
type: "dimension"
label: "Quantitative field"
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": {
"type": "boxplot",
"extent": "min-max",
"tooltip": @{options.tooltip.value}
},
"encoding": {
"x": {
"field": @{fields.a.name},
"type": "nominal"
},
"color": {
"field": @{fields.a.name},
"type": "nominal",
"legend": null
},
"y": {
"field": @{fields.b.name},
"type": "quantitative",
"scale": {"zero": false}
}
}
};;
}
```
### Horizontal Box Plot

```aml
// Box Plot with horizontal boxes. Select a categorical and quantitative items
// control colors, outliers, extent and font style
CustomChartDef box_plot_horizontal {
label: 'Horizontal Box Plot'
description: 'To compare the distribution of a value across categories using horizontal boxes, with control over colors, outliers, and extent.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option show_outliers {
type: 'toggle'
label: 'Show outliers'
default_value: false
}
option box_size {
type: 'number-input'
label: 'Box Size'
default_value: 40
}
option box_color {
type: 'color-picker'
label: 'Box Color'
default_value: '#255DD4'
}
option median_color {
type: 'color-picker'
label: 'Median Color'
default_value: 'white'
}
option extent {
type: 'input'
label: 'Outlier detection scale (may use "min-max" as input)'
default_value: 1.5
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": {
"type": "boxplot",
"extent": @{options.extent.value},
"size": @{options.box_size.value},
"outliers": @{options.show_outliers.value},
"box": {"cornerRadius": 2},
"median": {
"color": @{options.median_color.value},
"strokeWidth": 2
},
"rule": {"color": "#9CA3AF", "size": 1.5},
"ticks": {"color": "#9CA3AF", "size": 12}
},
"encoding": {
"x": {
"field": @{fields.value.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}
},
"y": {
"field": @{fields.dimension.name},
"type": "nominal"
},
"tooltip": {
"field": @{fields.value.name},
"type": "quantitative"
},
"color": {
"value": @{options.box_color.value}
}
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": true},
"axisY": {"domain": false, "grid": false}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
// Box Plot with horizontal boxes. Select a categorical and quantitative items
// control colors, outliers, extent and font style
CustomChart {
fields {
field group { // this is to define to holistics the first field input
type: "dimension"
label: "Group by category"
}
field measure { // this is to define to holistics the second field input
type: "dimension"
label: "Value to analyze"
data_type: "number"
}
}
options {
option show_outliers {
type: 'toggle'
label: 'Show outliers'
default_value: false
}
option box_size {
type: 'number-input'
label: 'Box Size'
default_value: 90
}
option font {
type: 'select'
label: 'Chart Labels Font'
options: ['monospace', 'sans-serif', 'serif', 'sans']
default_value: 'sans-serif'
}
option box_color {
type: 'color-picker'
label: 'Box Color'
default_value: '#0091B3'
}
option median_color {
type: 'color-picker'
label: 'Median Color'
default_value: 'red'
}
option bckgrnd {
type: 'color-picker'
label: 'Chart Background Color'
default_value: 'white'
}
option extent {
type: 'input'
label: 'Outlier detetction scale (may use "min-max" as input)'
default_value: 1.5
}
}
template: @vgl {
"data": {
"values": @{values}
},
"config": {
"font": @{options.font.value},
"background": @{options.bckgrnd.value},
"autosize": {"type": "fit"}
},
"mark": {
"type": "boxplot",
"extent": @{options.extent.value},
"median": {
"color": @{options.median_color.value}
},
"outliers": @{options.show_outliers.value},
"ticks": true
},
"encoding": {
"x": {
"field": @{fields.measure.name},
"type": "quantitative"
},
"y": {
"field": @{fields.group.name},
"type": "nominal",
"scale": {"zero": false}
},
"tooltip": {
"field": @{fields.measure.name},
"type": "quantitative"
},
"color": {
"value": @{options.box_color.value}
},
"size": {"value": @{options.box_size.value}},
}
};;
}
```
### Vertical Box Plot

```aml
// Box Plot with vertical boxes. Select a categorical and quantitative items
// control colors, outliers, extent and font style
CustomChartDef box_plot_vertical {
label: 'Vertical Box Plot'
description: 'To compare the distribution of a value across categories using vertical boxes, with control over colors, outliers, and extent.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option show_outliers {
type: 'toggle'
label: 'Show outliers'
default_value: false
}
option box_size {
type: 'number-input'
label: 'Box Size'
default_value: 40
}
option box_color {
type: 'color-picker'
label: 'Box Color'
default_value: '#255DD4'
}
option median_color {
type: 'color-picker'
label: 'Median Color'
default_value: 'white'
}
option extent {
type: 'input'
label: 'Outlier detection scale (may use "min-max" as input)'
default_value: 1.5
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": {
"type": "boxplot",
"extent": @{options.extent.value},
"size": @{options.box_size.value},
"outliers": @{options.show_outliers.value},
"box": {"cornerRadius": 2},
"median": {
"color": @{options.median_color.value},
"strokeWidth": 2
},
"rule": {"color": "#9CA3AF", "size": 1.5},
"ticks": {"color": "#9CA3AF", "size": 12}
},
"encoding": {
"x": {
"field": @{fields.dimension.name},
"type": "nominal"
},
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}
},
"tooltip": {
"field": @{fields.value.name},
"type": "quantitative"
},
"color": {
"value": @{options.box_color.value}
}
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "labelAngle": 0},
"axisY": {"domain": false, "grid": true}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
// Box Plot with vertical boxes. Select a categorical and quantitative items
// control colors, outliers, extent and font style
CustomChart {
fields {
field group { // this is to define to holistics the first field input
type: "dimension"
label: "Group by category"
}
field measure { // this is to define to holistics the second field input
type: "dimension"
label: "Value to analyze"
data_type: "number"
}
}
options {
option show_outliers {
type: 'toggle'
label: 'Show outliers'
default_value: false
}
option box_size {
type: 'number-input'
label: 'Box Size'
default_value: 90
}
option font {
type: 'select'
label: 'Chart Labels Font'
options: ['monospace', 'sans-serif', 'serif', 'sans']
default_value: 'sans-serif'
}
option box_color {
type: 'color-picker'
label: 'Box Color'
default_value: '#0091B3'
}
option median_color {
type: 'color-picker'
label: 'Median Color'
default_value: 'red'
}
option bckgrnd {
type: 'color-picker'
label: 'Chart Background Color'
default_value: 'white'
}
option extent {
type: 'input'
label: 'Outlier detetction scale (may use "min-max" as input)'
default_value: 1.5
}
}
template: @vgl {
"data": {
"values": @{values}
},
"config": {
"font": @{options.font.value},
"background": @{options.bckgrnd.value},
"autosize": {"type": "fit"}
},
"mark": {
"type": "boxplot",
"extent": @{options.extent.value},
"median": {
"color": @{options.median_color.value}
},
"outliers": @{options.show_outliers.value},
"ticks": true
},
"encoding": {
"x": {
"field": @{fields.group.name},
"type": "nominal"
},
"y": {
"field": @{fields.measure.name},
"type": "quantitative",
"scale": {"zero": false}
},
"tooltip": {
"field": @{fields.measure.name},
"type": "quantitative"
},
"color": {
"value": @{options.box_color.value}
},
"size": {"value": @{options.box_size.value}}
}
};;
}
```
## Required fields
Every variant expects exactly two fields: a category dimension and the numeric value to summarize. The fields are identical across the Basic, Horizontal, and Vertical variants, so the same table applies to all three.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category that splits the data into one box per group. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `dimension` | Numeric observations (`data_type: 'number'`) the box plot summarizes into median, quartiles, and whiskers. Sorted ascending (`apply_order: 2`). |
**Data requirements:** Pass raw, unaggregated rows (one row per observation), since the `boxplot` mark computes the median and quartiles itself; do not pre-aggregate to one value per category, or each box collapses to a single point.
**Sample data:** Each row is one observation. Repeat the category across many rows so the chart has a distribution to summarize.
| dimension | value |
|-----------|-------|
| North | 120 |
| North | 135 |
| North | 98 |
| North | 210 |
| South | 88 |
| South | 142 |
| South | 119 |
| South | 305 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values. The variants expose different option sets.
**Basic Box Plot:**
| Option | Default | Effect |
|-----------------|---------|--------|
| `tooltip` | `true` | Shows a tooltip with the box statistics on hover. |
| `show_outliers` | `false` | Plots individual outlier points beyond the whiskers when on. |
**Horizontal and Vertical Box Plot:** Both variants share the same options.
| Option | Default | Effect |
|-----------------|-------------|--------|
| `show_outliers` | `false` | Plots individual outlier points beyond the whiskers when on. |
| `box_size` | `40` | Thickness of each box, in pixels. |
| `box_color` | `#255DD4` | Fill color of the boxes. |
| `median_color` | `white` | Color of the median line inside each box. |
| `extent` | `1.5` | Whisker reach as a multiple of the interquartile range. Enter `min-max` to extend whiskers to the full data range. |
## Known limitations
- **Needs raw rows, not aggregates.** The mark derives quartiles from the underlying values, so pre-aggregated data (one value per category) leaves nothing to summarize.
- **Each category needs enough observations.** Boxes built from very few rows give misleading quartiles and whiskers. Make sure each group has a meaningful number of points.
- **Whisker extent changes which points count as outliers.** A higher `extent` (or `min-max`) pulls the whiskers out and reclassifies points, so the same data can look outlier-free or outlier-heavy depending on the setting.
---
## Bubble Plot
A bubble plot positions points by two quantitative values and uses the size of each circle to encode a third. It is a scatter plot with an added magnitude dimension.
- **Good for:** comparing three numeric measures at once, spotting outliers, correlation with a magnitude (for example, revenue vs. profit sized by order count).
- **Not great for:** part-to-whole composition (use a sunburst or treemap), category magnitudes without x/y coordinates (use a packed bubble chart), or time series.

## Syntax
Use the following AML definition to add the Bubble Chart to your custom chart library.
```aml
CustomChartDef bubble_chart {
label: 'Bubble Chart'
description: 'To visualize the magnitude of a measurement using the size of each circle.'
fields {
field x_axis {
label: 'X-axis'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field y_axis {
label: 'Y-axis'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field size {
label: 'Size'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 3
direction: 'asc'
}
}
field category {
label: 'Category'
type: 'dimension'
}
}
template: @vgl {
"data": {
"values": @{values}
},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "fields": [@{fields.category.name}], "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "fields": [@{fields.category.name}], "on": "mouseover", "clear": "mouseout"}}
],
"mark": {"type": "circle", "opacity": 0.7},
"encoding": {
"x": {
"field": @{fields.x_axis.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {"format": @{fields.x_axis.format}, "formatType": "holisticsFormat"}
},
"y": {
"field": @{fields.y_axis.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {"format": @{fields.y_axis.format}, "formatType": "holisticsFormat"}
},
"size": {
"field": @{fields.size.name},
"type": "quantitative",
"title": null,
"legend": {"orient": "bottom"}
},
"color": {
"field": @{fields.category.name},
"type": "nominal",
"legend": {"orient": "right", "title": null}
},
"tooltip": [
{"field": @{fields.category.name}, "type": "nominal"},
{"field": @{fields.x_axis.name}, "type": "quantitative", "format": @{fields.x_axis.format}, "formatType": "holisticsFormat"},
{"field": @{fields.y_axis.name}, "type": "quantitative", "format": @{fields.y_axis.format}, "formatType": "holisticsFormat"},
{"field": @{fields.size.name}, "type": "quantitative", "format": @{fields.size.format}, "formatType": "holisticsFormat"}
]
},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": true, "labelAngle": 0},
"axisY": {"domain": false, "grid": true},
"legend": {"orient": "bottom", "title": null, "labelFontSize": 11, "labelColor": "#858B9E", "symbolStrokeWidth": 0}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field x_axis {
type: "dimension"
label: "X axis"
}
field y_axis {
type: "dimension"
label: "Y axis"
}
field size {
type: "dimension"
label: "Size"
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": {
"type": "circle",
"opacity": 0.8,
"stroke": "black",
"strokeWidth": 1
},
"encoding": {
"x": {
"field": @{fields.x_axis.name},
"type": "temporal",
"axis": {"grid": false}
},
"y": {
"field": @{fields.y_axis.name},
"type": "nominal",
"axis": {"title": ""}
},
"size": {
"field": @{fields.size.name},
"type": "quantitative",
"title": "Annual Global Deaths",
"legend": {"clipHeight": 30},
"scale": {"rangeMax": 5000}
},
"color": {"field": @{fields.y_axis.name}, "type": "nominal", "legend": null}
}
};;
}
```
## Required fields
A Bubble Chart expects four fields. Each row of input is one bubble, identified by its category.
| Field | Label | Type | Role |
|------------|----------|-------------|------|
| `x_axis` | X-axis | `dimension` | Horizontal position; read as a quantitative value. Sorted ascending (`apply_order: 1`). |
| `y_axis` | Y-axis | `dimension` | Vertical position; read as a quantitative value. Sorted ascending (`apply_order: 2`). |
| `size` | Size | `dimension` | Bubble area; larger values render as larger circles. Sorted ascending (`apply_order: 3`). |
| `category` | Category | `dimension` | Names each bubble, colors bubbles by category, and is the field a click cross-filters on. |
**Data requirements:** Pre-aggregate to one row per bubble; the template does not aggregate, so each input row draws its own circle. Use numeric values for x, y, and size (all plotted as quantitative); category is a nominal label.
**Sample data:**
| category | x_axis | y_axis | size |
|-------------|--------|--------|------|
| Chairs | 12000 | 3200 | 540 |
| Tables | 8500 | 2100 | 320 |
| Phones | 21000 | 6400 | 910 |
| Binders | 4300 | 900 | 150 |
| Accessories | 15600 | 4800 | 720 |
## Known limitations
- **X, Y, and size must be numeric.** The template encodes X, Y, and size as quantitative, so non-numeric values for those fields will not plot correctly.
- **Rows are not aggregated.** Duplicate x/y points each draw a separate overlapping circle, so pre-aggregate before charting.
---
## Bullet Chart
A bullet chart shows a measure against a target and qualitative ranges, packing KPI-versus-goal context into a compact bar.
- **Good for:** tracking KPIs against a goal, comparing actual versus target versus pace across categories, compact scorecards on a dashboard.
- **Not great for:** showing a trend over time (use a line chart), comparing many measures at once, or a single metric with no target (use a gauge chart).

## Syntax
Use the following AML definition to add the Bullet Chart to your custom chart library.
```aml
CustomChartDef bullet_chart {
label: 'Bullet Chart'
description: 'To compare target, pace, and current values across categories in a compact bullet chart layout.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field target {
label: 'Target'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
field pace {
label: 'Pace'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
field current {
label: 'Current'
type: 'measure'
sort {
apply_order: 4
direction: 'desc'
}
}
}
options {
option show_tooltip {
label: 'Show tooltip'
type: 'toggle'
default_value: true
}
option current_bar_height {
label: 'Current bar height'
type: 'number-input'
default_value: 8
}
option target_tick_thickness {
label: 'Target tick thickness'
type: 'number-input'
default_value: 2
}
}
template: @vgl {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"description": "A reusable target-vs-pace-vs-current bullet-style comparison chart.",
"data": {"values": @{values}},
"transform": [
{
"calculate": "datum['@{fields.target.name}']",
"as": "target_value"
},
{
"calculate": "datum['@{fields.pace.name}']",
"as": "pace_value"
},
{
"calculate": "datum['@{fields.current.name}']",
"as": "current_value"
},
{
"calculate": "datum['@{fields.dimension.name}']",
"as": "category_value"
},
{
"fold": ["target_value", "pace_value", "current_value"],
"as": ["series_key", "series_value"]
},
{
"calculate": "{'target_value':'Target','pace_value':'Pace','current_value':'Current'}[datum.series_key]",
"as": "series_label"
},
{
"calculate": "indexof(['Target','Pace','Current'], datum.series_label)",
"as": "series_order"
}
],
"encoding": {
"y": {
"field": "category_value",
"type": "ordinal"
},
"x": {
"field": "series_value",
"type": "quantitative",
"stack": null
},
"order": {
"field": "series_order",
"type": "quantitative",
"sort": "ascending"
},
"tooltip": [
{
"field": "category_value",
"type": "nominal",
"title": "Category"
},
{
"field": "current_value",
"type": "quantitative",
"title": "Current"
},
{
"field": "pace_value",
"type": "quantitative",
"title": "Pace"
},
{
"field": "target_value",
"type": "quantitative",
"title": "Target"
}
]
},
"layer": [
{
"mark": "bar",
"params": [
{
"name": "series_hover",
"select": {
"type": "point",
"fields": ["series_label"]
},
"bind": {"legend": "pointerover"}
},
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"color": {
"field": "series_label",
"type": "nominal",
"legend": {},
"scale": {
"domain": ["Target", "Pace", "Current"],
"range": ["#9CA3AF", "#E5E7EB", "#4F46E5"]
}
},
"opacity": {
"value": 0
}
}
},
{
"transform": [
{
"filter": "datum.series_label === 'Pace'"
}
],
"mark": {
"type": "bar",
"tooltip": @{options.show_tooltip.value}
},
"encoding": {
"color": {
"field": "series_label",
"type": "nominal",
"legend": null,
"scale": {
"domain": ["Target", "Pace", "Current"],
"range": ["#9CA3AF", "#E5E7EB", "#4F46E5"]
}
},
"opacity": {
"condition": {
"param": "series_hover",
"value": 1
},
"value": 0.25
}
}
},
{
"transform": [
{
"filter": "datum.series_label === 'Current'"
}
],
"mark": {
"type": "bar",
"height": @{options.current_bar_height.value},
"tooltip": @{options.show_tooltip.value}
},
"encoding": {
"color": {
"field": "series_label",
"type": "nominal",
"legend": null,
"scale": {
"domain": ["Target", "Pace", "Current"],
"range": ["#9CA3AF", "#E5E7EB", "#4F46E5"]
}
},
"opacity": {
"condition": {
"param": "series_hover",
"value": 1
},
"value": 0.25
}
}
},
{
"transform": [
{
"filter": "datum.series_label === 'Target'"
}
],
"mark": {
"type": "tick",
"thickness": @{options.target_tick_thickness.value},
"tooltip": @{options.show_tooltip.value}
},
"encoding": {
"color": {
"field": "series_label",
"type": "nominal",
"legend": null,
"scale": {
"domain": ["Target", "Pace", "Current"],
"range": ["#9CA3AF", "#E5E7EB", "#4F46E5"]
}
},
"opacity": {
"condition": {
"param": "series_hover",
"value": 1
},
"value": 0.25
}
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": true},
"axisY": {"domain": false, "grid": false},
"legend": {
"orient": "top",
"direction": "horizontal",
"symbolType": "square",
"symbolSize": 80,
"labelLimit": 140,
"symbolOpacity": 1,
"title": null,
"labelFontSize": 11,
"labelColor": "#858B9E",
"symbolStrokeWidth": 0
},
"bar": {
"cornerRadius": 2
},
"tick": {
"thickness": 2,
"size": 22
}
}
};;
}
```
## Required fields
A Bullet Chart expects exactly four fields. Each row is one category with its target, pace, and current values.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category on the y axis (one bullet per value). Sorted ascending (`apply_order: 1`). |
| `target` | Target | `measure` | Goal value, drawn as a vertical tick. Sorted descending (`apply_order: 2`). |
| `pace` | Pace | `measure` | Expected-to-date value, drawn as the wide background bar. Sorted descending (`apply_order: 3`). |
| `current` | Current | `measure` | Actual value, drawn as the thin foreground bar. Sorted descending (`apply_order: 4`). |
**Data requirements:** Pre-aggregate to one row per `dimension` value; the template folds the three measures per row but does not combine duplicate categories.
**Sample data:**
| dimension | target | pace | current |
|-------------|--------|------|---------|
| North | 1000 | 750 | 820 |
| South | 1200 | 900 | 640 |
| East | 800 | 600 | 710 |
| West | 1500 | 1125 | 1180 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|-------------------------|---------|--------|
| `show_tooltip` | `true` | Toggles the hover tooltip showing category, current, pace, and target. |
| `current_bar_height` | `8` | Height in pixels of the thin Current bar. |
| `target_tick_thickness` | `2` | Thickness in pixels of the Target tick mark. |
## Known limitations
- **All three measures must be on the same scale.** Target, pace, and current share one x axis, so they need comparable units for the bullet to read correctly.
- **One value per category.** The template does not aggregate, so duplicate `dimension` rows draw overlapping marks. Pre-aggregate to a single row per category.
- **Fixed three-series layout.** The chart always draws target, pace, and current. It cannot show additional series or qualitative range bands without editing the template.
---
## Bump Chart
A bump chart tracks how categories rank against each other over time, highlighting overtakes and trend reversals.
- **Good for:** rank-over-time stories like top sellers by month, leaderboard movement, or competitive standings where overtakes matter more than raw values.
- **Not great for:** comparing exact values (use a line chart), a single before-and-after comparison (use the [Slope Chart](/docs/charts/custom-charts/library/slope-chart)), or more than ~10 categories where lines crowd together.

## Syntax
Use the following AML definition to add the Bump Chart to your custom chart library.
```aml
CustomChartDef bump_chart {
label: 'Bump Chart'
description: 'To track how categories rank against each other over time, highlighting overtakes and trend reversals.'
fields {
field period {
label: 'Period'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
option color_scheme {
type: 'select'
label: 'Color scheme'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'pastel1', 'pastel2', 'set1', 'set2', 'set3']
default_value: 'tableau10'
}
option line_interpolate {
type: 'select'
label: 'Line style'
options: ['linear', 'monotone']
default_value: 'monotone'
}
option point_size {
type: 'select'
label: 'Point size'
options: [60, 100, 140, 180]
default_value: 100
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 10" }]
},
{
"name": "height",
"init": "containerSize()[1] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 10" }]
},
{
"name": "hovered",
"value": null,
"on": [
{"events": "symbol:mouseover", "update": "datum.category"},
{"events": "symbol:mouseout", "update": "null"},
{"events": "@legendSymbol:mouseover, @legendLabel:mouseover", "update": "datum.value"},
{"events": "@legendSymbol:mouseout, @legendLabel:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "symbol:click, line:click", "update": "{'@{fields.dimension.name}': [datum.category]}"},
{"events": "@legendSymbol:click, @legendLabel:click", "update": "{'@{fields.dimension.name}': [datum.value]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "symbol:mouseover, line:mouseover", "update": "{'@{fields.dimension.name}': [datum.category]}"},
{"events": "symbol:mouseout, line:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "table",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.period.name}']", "as": "period"},
{"type": "formula", "expr": "datum['@{fields.dimension.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{
"type": "window",
"sort": {"field": "amount", "order": "descending"},
"ops": ["rank"],
"as": ["rank"],
"groupby": ["period"]
},
{
"type": "collect",
"sort": {"field": "period"}
}
]
}
],
"scales": [
{
"name": "x",
"type": "point",
"domain": {"data": "table", "field": "period", "sort": true},
"range": "width"
},
{
"name": "y",
"type": "point",
"domain": {"data": "table", "field": "rank", "sort": true},
"range": "height"
},
{
"name": "color",
"type": "ordinal",
"domain": {"data": "table", "field": "category"},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"axes": [
{"orient": "bottom", "scale": "x"},
{"orient": "left", "scale": "y", "title": "Rank"}
],
"legends": [
{
"fill": "color",
"title": null,
"encode": {
"symbols": {
"name": "legendSymbol",
"interactive": true,
"update": {
"opacity": {"signal": "hovered === null || hovered === datum.value ? 1 : 0.15"}
}
},
"labels": {
"name": "legendLabel",
"interactive": true,
"update": {
"opacity": {"signal": "hovered === null || hovered === datum.value ? 1 : 0.3"}
}
}
}
}
],
"marks": [
{
"type": "group",
"from": {"facet": {"name": "series", "data": "table", "groupby": "category"}},
"marks": [
{
"type": "line",
"name": "bumpLine",
"from": {"data": "series"},
"encode": {
"update": {
"x": {"scale": "x", "field": "period"},
"y": {"scale": "y", "field": "rank"},
"stroke": {"scale": "color", "field": "category"},
"interpolate": {"value": @{options.line_interpolate.value}},
"strokeWidth": {"signal": "hovered === datum.category ? 4 : 2"},
"opacity": {"signal": "hovered === null || hovered === datum.category ? 1 : 0.15"}
}
}
},
{
"type": "symbol",
"name": "bumpPoint",
"from": {"data": "series"},
"encode": {
"update": {
"x": {"scale": "x", "field": "period"},
"y": {"scale": "y", "field": "rank"},
"fill": {"scale": "color", "field": "category"},
"size": {"value": @{options.point_size.value}},
"opacity": {"signal": "hovered === null || hovered === datum.category ? 1 : 0.15"},
"tooltip": {
"signal": "datum.category + ' - ' + datum.period + ': rank ' + datum.rank + ' (' + format(datum.amount, ',') + ')'"
}
}
}
}
]
}
],
"config": {
"background": null,
"axis": {
"domain": false,
"ticks": false,
"labelPadding": 10,
"labelColor": "#858B9E",
"labelFont": "Inter",
"labelFontSize": 11,
"titleColor": "#858B9E",
"titleFont": "Inter",
"titleFontSize": 11
},
"axisX": {"labelAngle": 0},
"axisY": {"grid": true, "gridDash": [8, 3], "gridColor": "#F4F6F8"},
"legend": {
"orient": "top",
"direction": "horizontal",
"symbolType": "circle",
"labelColor": "#858B9E",
"labelFont": "Inter",
"labelFontSize": 11
}
}
}
;;
}
```
## Required fields
A Bump Chart expects exactly three fields. Each row of input is one category's value in one period, and the template ranks categories within each period.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `period` | Period | `dimension` | Position on the x-axis; one column of points per period. Sorted ascending (`apply_order: 1`). |
| `dimension` | Dimension | `dimension` | The category whose rank the chart tracks, with one line per category. Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | The amount used to rank categories within each period (highest value ranks first). Sorted descending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per period and category, since the template ranks rows directly and does not sum duplicates. Provide a value for every category in every period so each line is continuous, and use at least two periods so ranks have something to move between.
**Sample data:**
| period | dimension | value |
|---------|-----------|-------|
| 2024-01 | Product A | 4200 |
| 2024-01 | Product B | 3800 |
| 2024-01 | Product C | 2100 |
| 2024-02 | Product A | 3100 |
| 2024-02 | Product B | 3900 |
| 2024-02 | Product C | 3500 |
| 2024-03 | Product A | 2600 |
| 2024-03 | Product B | 3000 |
| 2024-03 | Product C | 4100 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|-------------------|-------------|--------|
| `color_scheme` | `tableau10` | Ordinal color palette applied to the category lines and legend. |
| `line_interpolate`| `monotone` | Line shape between points. `monotone` draws smooth curves; `linear` draws straight segments. |
| `point_size` | `100` | Size of the point markers at each period. |
## Known limitations
- **Needs one value per category per period.** A category missing in a period breaks its line and shifts ranks for that column, so fill gaps before charting.
- **Shows rank, not magnitude.** The y-axis is rank order, so equal gaps between ranks can hide large value differences. Use a line chart when the actual values matter.
- **Readability drops past ~10 categories.** Beyond roughly 10 lines the ranks crowd and crossings get hard to follow. Group smaller categories together first.
---
## Candlestick Chart
A candlestick chart is essentially a sequence of box-plot-like bars placed side by side. It is commonly used to visualize the price movement of financial instruments such as forex, stocks, and bonds.
- **Good for:** open-high-low-close price movement over time, stock and forex trading sessions, daily or weekly trading ranges.
- **Not great for:** a single value per period (use a line or bar chart), non-time-series data, or categories without high and low bounds.
## Syntax
Use the following AML definition to add the Candlestick Chart to your custom chart library.
```aml
CustomChartDef candlestick_chart {
label: 'Candlestick Chart'
description: 'To visualize the open, high, low, and close price movement of financial instruments over time.'
fields {
field date {
label: 'Date'
type: 'dimension'
data_type: 'date'
sort {
apply_order: 1
direction: 'asc'
}
}
field low {
label: 'Low'
type: 'measure'
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
field high {
label: 'High'
type: 'measure'
data_type: 'number'
sort {
apply_order: 3
direction: 'asc'
}
}
field open {
label: 'Open'
type: 'measure'
data_type: 'number'
sort {
apply_order: 4
direction: 'asc'
}
}
field close {
label: 'Close'
type: 'measure'
data_type: 'number'
sort {
apply_order: 5
direction: 'asc'
}
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
option green_candle {
type: 'color-picker'
label: 'Green candlestick'
default_value: 'green'
}
option red_candle {
type: 'color-picker'
label: 'Red candlestick'
default_value: 'red'
}
}
template: @vgl
{
"data": {
"values": @{values}
},
"layer": [
{
"mark": "rule",
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"y": {"field": @{fields.low.name}},
"y2": {"field": @{fields.high.name}}
}
},
{
"mark": {
"type": "bar",
"tooltip": @{options.tooltip.value}
},
"encoding": {
"y": {"field": @{fields.open.name}},
"y2": {"field": @{fields.close.name}}
}
}
],
"encoding": {
"x": {
"type": "temporal",
"field": @{fields.date.name}
},
"y": {
"axis": {
"title": "Price"
},
"type": "quantitative",
"scale": {
"zero": false
}
},
"color": {
"condition": {
"test": "datum['@{fields.open.name}'] < datum['@{fields.close.name}']",
"value": @{options.green_candle.value}
},
"value": @{options.red_candle.value}
}
},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "format": "%b %Y", "labelAngle": 0},
"axisY": {"domain": false, "grid": true}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field date {
type: "dimension"
label: "Date field"
}
field low {
type: "dimension"
label: "Low price"
}
field high {
type: "dimension"
label: "High price"
}
field open {
type: "dimension"
label: "Open price"
}
field close {
type: "dimension"
label: "Close price"
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
option green_candle {
type: 'color-picker'
label: 'Green candlestick'
default_value: 'green'
}
option red_candle {
type: 'color-picker'
label: 'Red candlestick'
default_value: 'red'
}
}
template: @vgl
{
"data": {
"values": @{values}
},
"layer": [
{
"mark": "rule",
"encoding": {
"y": {"field": @{fields.low.name}},
"y2": {"field": @{fields.high.name}}
}
},
{
"mark": {
"type": "bar",
"tooltip": @{options.tooltip.value}
},
"encoding": {
"y": {"field": @{fields.open.name}},
"y2": {"field": @{fields.close.name}}
}
}
],
"encoding": {
"x": {
"axis": {
"format": "%m/%d",
"labelAngle": -45
},
"type": "temporal",
"field": @{fields.date.name},
},
"y": {
"axis": {
"title": "Price"
},
"type": "quantitative",
"scale": {
"zero": false
}
},
"color": {
"condition": {
"test": "datum.@{fields.open.name} < datum.@{fields.close.name}",
"value": @{options.green_candle.value}
},
"value": @{options.red_candle.value}
}
}
};;
}
```
## Required fields
A Candlestick Chart expects exactly five fields. Each row of input is one period (one candle) with its open, high, low, and close values.
| Field | Label | Type | Role |
|---------|-------|-------------|------|
| `date` | Date | `dimension` | Time period for each candle; sets the x position. Sorted ascending (`apply_order: 1`). |
| `low` | Low | `measure` | Lowest price; bottom of the wick. Sorted ascending (`apply_order: 2`). |
| `high` | High | `measure` | Highest price; top of the wick. Sorted ascending (`apply_order: 3`). |
| `open` | Open | `measure` | Opening price; one end of the candle body. Sorted ascending (`apply_order: 4`). |
| `close` | Close | `measure` | Closing price; the other end of the candle body. Sorted ascending (`apply_order: 5`). |
**Data requirements:** Pre-aggregate to one row per date; the template does not combine duplicate periods. Each row needs all four price values, and the template colors a candle with the green option when `open` is less than `close` (otherwise the red option).
**Sample data:**
| date | low | high | open | close |
|------------|--------|--------|--------|--------|
| 2024-01-02 | 184.30 | 188.44 | 187.15 | 185.64 |
| 2024-01-03 | 183.89 | 185.88 | 184.22 | 184.25 |
| 2024-01-04 | 181.59 | 183.09 | 182.15 | 181.91 |
| 2024-01-05 | 180.17 | 182.76 | 181.99 | 181.18 |
| 2024-01-08 | 181.50 | 185.60 | 182.09 | 185.56 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|----------------|---------|--------|
| `tooltip` | `true` | Shows a tooltip on hover over each candle body. |
| `green_candle` | `green` | Fill color for up periods, where `close` is higher than `open`. |
| `red_candle` | `red` | Fill color for down periods, where `close` is at or below `open`. |
## Known limitations
- **Every row needs all four price values.** Each candle reads `open`, `high`, `low`, and `close`. Rows missing any of these render incompletely.
- **One row per period.** The template does not aggregate, so duplicate dates draw overlapping candles. Pre-aggregate to a single open-high-low-close row per period.
- **The y-axis does not start at zero.** The scale is set to fit the price range (`zero: false`), which is right for price data but means bar lengths are not proportional to absolute value.
---
## Chord Diagram
A chord diagram visualizes flows and their magnitude between a set of categories. Each category is an arc around a circle, and curved bands (chords) connect them: the arc size reflects a category's total volume, and a chord's width reflects the strength of connection between two categories.
- **Good for:** many-to-many relationships between categories, movement or flow between groups, source-target pairs with values (country-to-country trade, customer movement between subscription plans).
- **Not great for:** one-way funnels or journeys (use a Sankey Chart), hierarchical part-to-whole data (use a Treemap or Sunburst Chart), or more than ~15 categories around the ring.
## Syntax
Use the following AML definition to add the Chord Diagram to your custom chart library.
```aml
CustomChartDef chord_diagram {
label: 'Chord Diagram'
description: 'To visualize the flows, directions, and magnitude of source-target relationships between a set of categories.'
fields {
field source {
label: 'Source'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field target {
label: 'Target'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
option pad_angle {
label: "Pad Angle"
type: "number-input"
default_value: 0.05
}
option inner_radius_ratio {
label: "Inner Radius Ratio"
type: "number-input"
default_value: 0.9
}
option label_padding {
label: "Label Padding"
type: "number-input"
default_value: 80
}
}
template: @vgl
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 600,
"height": 600,
"autosize": "none",
"data": [
{
"name": "table",
"values": @{values}
},
{
"name": "chordData",
"source": "table",
"transform": [
{
"type": "chord",
"source": "datum['@{fields.source.name}']",
"target": "datum['@{fields.target.name}']",
"value": "datum['@{fields.value.name}']",
"padAngle": @{options.pad_angle.value},
"innerRadiusRatio": @{options.inner_radius_ratio.value},
"labelPadding": @{options.label_padding.value}
}
]
},
{
"name": "uniqueGroups",
"source": "chordData",
"transform": [
{
"type": "formula",
"expr": "datum.sourceGroup.id",
"as": "groupId"
},
{
"type": "formula",
"expr": "datum.sourceGroup.startAngle",
"as": "groupStartAngle"
},
{
"type": "formula",
"expr": "datum.sourceGroup.endAngle",
"as": "groupEndAngle"
},
{
"type": "formula",
"expr": "(datum.sourceGroup.startAngle + datum.sourceGroup.endAngle) / 2",
"as": "groupMidAngle"
},
{
"type": "formula",
"expr": "datum.sourceGroup.value",
"as": "groupValue"
},
{
"type": "aggregate",
"groupby": ["groupId", "groupStartAngle", "groupEndAngle", "groupMidAngle", "groupValue"]
}
]
},
{
"name": "targetGroups",
"source": "chordData",
"transform": [
{
"type": "formula",
"expr": "datum.targetGroup.id",
"as": "groupId"
},
{
"type": "formula",
"expr": "datum.targetGroup.startAngle",
"as": "groupStartAngle"
},
{
"type": "formula",
"expr": "datum.targetGroup.endAngle",
"as": "groupEndAngle"
},
{
"type": "formula",
"expr": "(datum.targetGroup.startAngle + datum.targetGroup.endAngle) / 2",
"as": "groupMidAngle"
},
{
"type": "formula",
"expr": "datum.targetGroup.value",
"as": "groupValue"
},
{
"type": "aggregate",
"groupby": ["groupId", "groupStartAngle", "groupEndAngle", "groupMidAngle", "groupValue"]
}
]
},
{
"name": "allGroups",
"source": ["uniqueGroups", "targetGroups"],
"transform": [
{
"type": "aggregate",
"groupby": ["groupId", "groupStartAngle", "groupEndAngle", "groupMidAngle", "groupValue"]
}
]
}
],
"signals": [
{
"name": "width",
"init": "(containerSize()[0])",
"on": [
{
"update": "(containerSize()[0])",
"events": "window:resize"
}
]
},
{
"name": "height",
"init": "(containerSize()[1])",
"on": [
{
"update": "(containerSize()[1])",
"events": "window:resize"
}
]
},
{
"name": "labelPadding",
"update": "@{options.label_padding.value}"
},
{
"name": "outerRadius",
"update": "max(min(width, height) / 2 - labelPadding, 10)"
},
{
"name": "innerRadius",
"update": "outerRadius * @{options.inner_radius_ratio.value}"
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@groupArc:click", "update": "{'@{fields.source.name}': [datum.groupId]}"},
{"events": "@chordRibbon:click", "update": "{'@{fields.source.name}': [datum.sourceGroup.id], '@{fields.target.name}': [datum.targetGroup.id]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@groupArc:mouseover", "update": "{'@{fields.source.name}': [datum.groupId]}"},
{"events": "@chordRibbon:mouseover", "update": "{'@{fields.source.name}': [datum.sourceGroup.id], '@{fields.target.name}': [datum.targetGroup.id]}"},
{"events": "@groupArc:mouseout, @chordRibbon:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"scales": [
{
"name": "color",
"type": "ordinal",
"range": "category",
"domain": {
"data": "allGroups",
"field": "groupId"
}
}
],
"marks": [
{
"type": "group",
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"}
}
},
"marks": [
{
"type": "arc",
"name": "groupArc",
"from": {"data": "allGroups"},
"encode": {
"update": {
"startAngle": {"field": "groupStartAngle"},
"endAngle": {"field": "groupEndAngle"},
"innerRadius": {"signal": "innerRadius"},
"outerRadius": {"signal": "outerRadius"},
"fill": {"scale": "color", "field": "groupId"},
"stroke": {"value": "#fff"},
"strokeWidth": {"value": 0.5},
"tooltip": {"signal": "datum.groupId + ': ' + datum.groupValue"}
},
"hover": {
"fillOpacity": {"value": 0.8}
}
}
},
{
"type": "path",
"name": "chordRibbon",
"from": {"data": "chordData"},
"encode": {
"update": {
"path": {"field": "ribbonPath"},
"fill": {"scale": "color", "field": "sourceId"},
"fillOpacity": {"value": 0.67},
"stroke": {"value": "#fff"},
"strokeWidth": {"value": 0.5},
"tooltip": {"signal": "datum.sourceId + ' → ' + datum.targetId + ': ' + datum.chordValue"}
},
"hover": {
"fillOpacity": {"value": 0.9}
}
}
},
{
"type": "text",
"name": "groupLabel",
"from": {"data": "allGroups"},
"encode": {
"update": {
"x": {"signal": "(outerRadius + 5) * cos((datum.groupMidAngle) - PI / 2)"},
"y": {"signal": "(outerRadius + 5) * sin((datum.groupMidAngle) - PI / 2)"},
"align": {"signal": "datum.groupMidAngle > PI ? 'right' : 'left'"},
"baseline": {"value": "middle"},
"fontWeight": {"value": "normal"},
"fontSize": {"value": 11},
"text": {"field": "groupId"},
"angle": {"signal": "datum.groupMidAngle > PI ? (datum.groupMidAngle - PI / 2) * 180 / PI - 180 : (datum.groupMidAngle - PI / 2) * 180 / PI"},
"limit": {"signal": "max(labelPadding - 10, 20)"},
"ellipsis": {"value": "…"},
"tooltip": {"field": "groupId"}
}
}
}
]
}
]
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field source {
type: "dimension"
label: "Source"
data_type: "string"
}
field target {
type: "dimension"
label: "Target"
data_type: "string"
}
field value {
type: "measure"
label: "Flow value"
}
}
options {
option pad_angle {
label: "Pad Angle"
type: "number-input"
default_value: 0.05
}
option inner_radius_ratio {
label: "Inner Radius Ratio"
type: "number-input"
default_value: 0.9
}
option label_padding {
label: "Label Padding"
type: "number-input"
default_value: 80
}
}
template: @vgl
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 600,
"height": 600,
"autosize": "none",
"data": [
{
"name": "table",
"values": @{values}
},
{
"name": "chordData",
"source": "table",
"transform": [
{
"type": "chord",
"source": "datum['@{fields.source.name}']",
"target": "datum['@{fields.target.name}']",
"value": "datum['@{fields.value.name}']",
"padAngle": @{options.pad_angle.value},
"innerRadiusRatio": @{options.inner_radius_ratio.value},
"labelPadding": @{options.label_padding.value}
}
]
},
{
"name": "uniqueGroups",
"source": "chordData",
"transform": [
{
"type": "formula",
"expr": "datum.sourceGroup.id",
"as": "groupId"
},
{
"type": "formula",
"expr": "datum.sourceGroup.startAngle",
"as": "groupStartAngle"
},
{
"type": "formula",
"expr": "datum.sourceGroup.endAngle",
"as": "groupEndAngle"
},
{
"type": "formula",
"expr": "(datum.sourceGroup.startAngle + datum.sourceGroup.endAngle) / 2",
"as": "groupMidAngle"
},
{
"type": "formula",
"expr": "datum.sourceGroup.value",
"as": "groupValue"
},
{
"type": "aggregate",
"groupby": ["groupId", "groupStartAngle", "groupEndAngle", "groupMidAngle", "groupValue"]
}
]
},
{
"name": "targetGroups",
"source": "chordData",
"transform": [
{
"type": "formula",
"expr": "datum.targetGroup.id",
"as": "groupId"
},
{
"type": "formula",
"expr": "datum.targetGroup.startAngle",
"as": "groupStartAngle"
},
{
"type": "formula",
"expr": "datum.targetGroup.endAngle",
"as": "groupEndAngle"
},
{
"type": "formula",
"expr": "(datum.targetGroup.startAngle + datum.targetGroup.endAngle) / 2",
"as": "groupMidAngle"
},
{
"type": "formula",
"expr": "datum.targetGroup.value",
"as": "groupValue"
},
{
"type": "aggregate",
"groupby": ["groupId", "groupStartAngle", "groupEndAngle", "groupMidAngle", "groupValue"]
}
]
},
{
"name": "allGroups",
"source": ["uniqueGroups", "targetGroups"],
"transform": [
{
"type": "aggregate",
"groupby": ["groupId", "groupStartAngle", "groupEndAngle", "groupMidAngle", "groupValue"]
}
]
}
],
"signals": [
{
"name": "width",
"init": "(containerSize()[0])",
"on": [
{
"update": "(containerSize()[0])",
"events": "window:resize"
}
]
},
{
"name": "height",
"init": "(containerSize()[1])",
"on": [
{
"update": "(containerSize()[1])",
"events": "window:resize"
}
]
},
{
"name": "labelPadding",
"update": "@{options.label_padding.value}"
},
{
"name": "outerRadius",
"update": "max(min(width, height) / 2 - labelPadding, 10)"
},
{
"name": "innerRadius",
"update": "outerRadius * @{options.inner_radius_ratio.value}"
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"range": "category",
"domain": {
"data": "allGroups",
"field": "groupId"
}
}
],
"marks": [
{
"type": "group",
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"}
}
},
"marks": [
{
"type": "arc",
"name": "groupArc",
"from": {"data": "allGroups"},
"encode": {
"update": {
"startAngle": {"field": "groupStartAngle"},
"endAngle": {"field": "groupEndAngle"},
"innerRadius": {"signal": "innerRadius"},
"outerRadius": {"signal": "outerRadius"},
"fill": {"scale": "color", "field": "groupId"},
"stroke": {"value": "#fff"},
"strokeWidth": {"value": 0.5},
"tooltip": {"signal": "datum.groupId + ': ' + datum.groupValue"}
},
"hover": {
"fillOpacity": {"value": 0.8}
}
}
},
{
"type": "path",
"name": "chordRibbon",
"from": {"data": "chordData"},
"encode": {
"update": {
"path": {"field": "ribbonPath"},
"fill": {"scale": "color", "field": "sourceId"},
"fillOpacity": {"value": 0.67},
"stroke": {"value": "#fff"},
"strokeWidth": {"value": 0.5},
"tooltip": {"signal": "datum.sourceId + ' → ' + datum.targetId + ': ' + datum.chordValue"}
},
"hover": {
"fillOpacity": {"value": 0.9}
}
}
},
{
"type": "text",
"name": "groupLabel",
"from": {"data": "allGroups"},
"encode": {
"update": {
"x": {"signal": "(outerRadius + 5) * cos((datum.groupMidAngle) - PI / 2)"},
"y": {"signal": "(outerRadius + 5) * sin((datum.groupMidAngle) - PI / 2)"},
"align": {"signal": "datum.groupMidAngle > PI ? 'right' : 'left'"},
"baseline": {"value": "middle"},
"fontWeight": {"value": "normal"},
"fontSize": {"value": 11},
"text": {"field": "groupId"},
"angle": {"signal": "datum.groupMidAngle > PI ? (datum.groupMidAngle - PI / 2) * 180 / PI - 180 : (datum.groupMidAngle - PI / 2) * 180 / PI"},
"limit": {"signal": "max(labelPadding - 10, 20)"},
"ellipsis": {"value": "…"},
"tooltip": {"field": "groupId"}
}
}
}
]
}
]
};;
}
```
## Required fields
A Chord Diagram expects exactly three fields. Each row of input is one directed connection from a source category to a target category.
| Field | Label | Type | Role |
|----------|--------|-------------|------|
| `source` | Source | `dimension` | Originating category of the connection. Sorted ascending (`apply_order: 1`). |
| `target` | Target | `dimension` | Destination category of the connection. Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | Connection strength; sets the chord width and contributes to each arc's size. Sorted descending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per source-target pair (for example, `SUM(value)` grouped by `source` and `target`); the `chord` transform does not combine duplicate pairs. Categories that appear as both source and target share a single arc on the ring.
**Sample data:**
| source | target | value |
|-----------|-----------|-------|
| Basic | Pro | 320 |
| Basic | Enterprise| 90 |
| Pro | Basic | 140 |
| Pro | Enterprise| 210 |
| Enterprise| Pro | 70 |
| Enterprise| Basic | 40 |
## Options
Set these options to adjust the layout without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|----------------------|---------|--------|
| `pad_angle` | `0.05` | Angular gap between adjacent arcs around the ring, in radians. Increase to separate crowded categories. |
| `inner_radius_ratio` | `0.9` | Inner radius of the arcs as a fraction of the outer radius. Lower values make thicker arc bands. |
| `label_padding` | `80` | Space reserved outside the ring for category labels, in pixels. Increase if long labels are clipped. |
## Known limitations
- **Pre-aggregate first.** The `chord` transform does not sum duplicate source-target pairs, so repeated rows skew arc and chord sizes. Aggregate to one row per pair before charting.
- **Readability drops past ~15 categories.** Beyond roughly 15 categories around the ring the chords overlap and labels collide. Group small categories into an "Other" bucket first.
---
## Control Chart (XmR)
A control chart (XmR, also called a process behaviour chart) tracks a metric over time and separates real change from routine noise. It draws the metric with its natural process limits, plus a companion Moving Range panel showing point-to-point volatility.
- **Good for:** monitoring a metric over time for real change (weekly signups, defect counts, delivery times, support volume), spotting outliers and trends with statistical limits.
- **Not great for:** comparing categories side by side, part-to-whole composition, or data without a natural time or sequence order (use a bar or line chart instead).

## Syntax
Use the following AML definition to add the Control Chart to your custom chart library.
```aml
CustomChartDef control_chart {
label: 'Control Chart (XmR)'
description: 'To separate routine variation from real change, with XmR process limits and color-coded signal detection rules.'
fields {
field period {
label: 'Period'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option line_color {
label: 'Line Color'
type: 'color-picker'
default_value: '#255dd4'
}
option show_mr_chart {
label: 'Show Moving Range chart'
type: 'toggle'
default_value: true
}
option show_tooltip {
label: 'Show tooltip'
type: 'toggle'
default_value: true
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 52",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 52" }]
},
{
"name": "height",
"init": "containerSize()[1] - 64",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 64" }]
},
{"name": "showMr", "update": "@{options.show_mr_chart.value}"},
{"name": "plotW", "update": "width - 110"},
{"name": "xH", "update": "showMr ? (height - 48) * 0.62 : height - 18"},
{"name": "mrTop", "update": "18 + xH + 30"},
{"name": "mrH", "update": "showMr ? height - mrTop : 1"},
{
"name": "hoveredPeriod",
"value": null,
"on": [
{"events": "symbol:mouseover", "update": "datum.period"},
{"events": "symbol:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@xPoint:click, @mrPoint:click", "update": "{'@{fields.period.name}': [datum['period']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@xPoint:mouseover, @mrPoint:mouseover", "update": "{'@{fields.period.name}': [datum['period']]}"},
{"events": "@xPoint:mouseout, @mrPoint:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "source",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.period.name}']", "as": "period"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null"},
{"type": "collect", "sort": {"field": "period"}},
{"type": "window", "ops": ["lag"], "fields": ["amount"], "as": ["prev_amount"]},
{
"type": "formula",
"expr": "datum.prev_amount == null ? null : abs(datum.amount - datum.prev_amount)",
"as": "mr"
},
{"type": "joinaggregate", "fields": ["amount", "mr"], "ops": ["mean", "mean"], "as": ["xbar", "mrbar"]},
{"type": "formula", "expr": "datum.xbar + 2.66 * datum.mrbar", "as": "unpl"},
{"type": "formula", "expr": "datum.xbar - 2.66 * datum.mrbar", "as": "lnpl"},
{"type": "formula", "expr": "datum.xbar + 1.33 * datum.mrbar", "as": "uql"},
{"type": "formula", "expr": "datum.xbar - 1.33 * datum.mrbar", "as": "lql"},
{"type": "formula", "expr": "3.268 * datum.mrbar", "as": "mr_url"},
{"type": "formula", "expr": "datum.amount > datum.xbar ? 1 : 0", "as": "above"},
{"type": "formula", "expr": "datum.amount < datum.xbar ? 1 : 0", "as": "below"},
{"type": "formula", "expr": "datum.amount > datum.uql ? 1 : 0", "as": "qabove"},
{"type": "formula", "expr": "datum.amount < datum.lql ? 1 : 0", "as": "qbelow"},
{
"type": "window",
"ops": ["sum", "sum"],
"fields": ["qabove", "qbelow"],
"as": ["qabove4", "qbelow4"],
"frame": [-3, 0]
},
{
"type": "window",
"ops": ["sum", "sum"],
"fields": ["above", "below"],
"as": ["above8", "below8"],
"frame": [-7, 0]
},
{
"type": "formula",
"expr": "datum.amount > datum.unpl || datum.amount < datum.lnpl",
"as": "rule1"
},
{
"type": "formula",
"expr": "(datum.qabove === 1 && datum.qabove4 >= 3) || (datum.qbelow === 1 && datum.qbelow4 >= 3)",
"as": "rule2"
},
{
"type": "formula",
"expr": "(datum.above === 1 && datum.above8 >= 8) || (datum.below === 1 && datum.below8 >= 8)",
"as": "rule3"
},
{
"type": "formula",
"expr": "datum.rule1 ? 'strong' : (datum.rule2 ? 'moderate' : (datum.rule3 ? 'weak' : 'none'))",
"as": "severity"
},
{
"type": "formula",
"expr": "datum.rule1 ? 'Outside process limits' : (datum.rule2 ? '3 of 4 points near a limit' : (datum.rule3 ? 'Long run on one side of center' : 'Routine variation'))",
"as": "signal_desc"
}
]
},
{
"name": "stats",
"source": "source",
"transform": [
{"type": "aggregate", "fields": ["amount", "mr"], "ops": ["mean", "mean"], "as": ["xbar", "mrbar"]},
{"type": "formula", "expr": "datum.xbar + 2.66 * datum.mrbar", "as": "unpl"},
{"type": "formula", "expr": "datum.xbar - 2.66 * datum.mrbar", "as": "lnpl"},
{"type": "formula", "expr": "3.268 * datum.mrbar", "as": "mr_url"}
]
}
],
"scales": [
{
"name": "x",
"type": "point",
"domain": {"data": "source", "field": "period", "sort": true},
"range": [0, {"signal": "plotW"}],
"padding": 0.5
},
{
"name": "severityColor",
"type": "ordinal",
"domain": ["none", "weak", "moderate", "strong"],
"range": [@{options.line_color.value}, "#eab308", "#f97316", "#e5484d"]
}
],
"axes": [
{"orient": "bottom", "scale": "x"}
],
"marks": [
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"value": 0},
"y": {"value": 10},
"text": {"value": "Individual Values (X)"},
"fontSize": {"value": 11},
"fontWeight": {"value": 600},
"fill": {"value": "#6b7280"}
}
}
},
{
"type": "symbol",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW - 330"},
"y": {"value": 8},
"fill": {"value": "#e5484d"},
"size": {"value": 50}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW - 322"},
"y": {"value": 8},
"baseline": {"value": "middle"},
"text": {"value": "Outside limits"},
"fontSize": {"value": 10},
"fill": {"value": "#6b7280"}
}
}
},
{
"type": "symbol",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW - 225"},
"y": {"value": 8},
"fill": {"value": "#f97316"},
"size": {"value": 50}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW - 217"},
"y": {"value": 8},
"baseline": {"value": "middle"},
"text": {"value": "Near limit (3 of 4)"},
"fontSize": {"value": 10},
"fill": {"value": "#6b7280"}
}
}
},
{
"type": "symbol",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW - 95"},
"y": {"value": 8},
"fill": {"value": "#eab308"},
"size": {"value": 50}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW - 87"},
"y": {"value": 8},
"baseline": {"value": "middle"},
"text": {"value": "One-side run"},
"fontSize": {"value": 10},
"fill": {"value": "#6b7280"}
}
}
},
{
"type": "group",
"name": "xChart",
"encode": {
"update": {"x": {"value": 0}, "y": {"value": 18}, "width": {"signal": "plotW"}, "height": {"signal": "xH"}}
},
"scales": [
{
"name": "yx",
"type": "linear",
"nice": true,
"zero": false,
"domain": {"data": "source", "fields": ["amount", "unpl", "lnpl"]},
"range": [{"signal": "xH"}, 10]
}
],
"axes": [
{"orient": "left", "scale": "yx"}
],
"marks": [
{
"type": "rule",
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"value": 0},
"x2": {"signal": "plotW"},
"y": {"scale": "yx", "field": "unpl"},
"stroke": {"value": "#9ba1a6"},
"strokeDash": {"value": [4, 4]}
}
}
},
{
"type": "rule",
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"value": 0},
"x2": {"signal": "plotW"},
"y": {"scale": "yx", "field": "lnpl"},
"stroke": {"value": "#9ba1a6"},
"strokeDash": {"value": [4, 4]}
}
}
},
{
"type": "rule",
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"value": 0},
"x2": {"signal": "plotW"},
"y": {"scale": "yx", "field": "xbar"},
"stroke": {"value": "#e5484d"},
"strokeDash": {"value": [6, 4]}
}
}
},
{
"type": "text",
"interactive": false,
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"signal": "plotW + 8"},
"y": {"scale": "yx", "field": "unpl"},
"baseline": {"value": "middle"},
"text": {"signal": "'Upper limit ' + format(datum.unpl, ',.4')"},
"fontSize": {"value": 10},
"fill": {"value": "#9ba1a6"}
}
}
},
{
"type": "text",
"interactive": false,
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"signal": "plotW + 8"},
"y": {"scale": "yx", "field": "lnpl"},
"baseline": {"value": "middle"},
"text": {"signal": "'Lower limit ' + format(datum.lnpl, ',.4')"},
"fontSize": {"value": 10},
"fill": {"value": "#9ba1a6"}
}
}
},
{
"type": "text",
"interactive": false,
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"signal": "plotW + 8"},
"y": {"scale": "yx", "field": "xbar"},
"baseline": {"value": "middle"},
"text": {"signal": "'Average ' + format(datum.xbar, ',.4')"},
"fontSize": {"value": 10},
"fill": {"value": "#e5484d"}
}
}
},
{
"type": "rule",
"interactive": false,
"encode": {
"update": {
"x": {"scale": "x", "signal": "hoveredPeriod"},
"y": {"value": 4},
"y2": {"signal": "xH"},
"stroke": {"value": "#9ba1a6"},
"strokeDash": {"value": [3, 3]},
"opacity": {"signal": "hoveredPeriod === null ? 0 : 0.7"}
}
}
},
{
"type": "line",
"from": {"data": "source"},
"encode": {
"update": {
"x": {"scale": "x", "field": "period"},
"y": {"scale": "yx", "field": "amount"},
"stroke": {"value": @{options.line_color.value}},
"strokeWidth": {"value": 1.5}
}
}
},
{
"type": "symbol",
"name": "xPoint",
"from": {"data": "source"},
"encode": {
"update": {
"x": {"scale": "x", "field": "period"},
"y": {"scale": "yx", "field": "amount"},
"fill": {"scale": "severityColor", "field": "severity"},
"size": {"signal": "datum.period === hoveredPeriod ? 130 : (datum.severity === 'none' ? 40 : 90)"},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1},
"tooltip": {
"signal": "@{options.show_tooltip.value} ? {'Period': datum.period, 'Value': format(datum.amount, ','), 'Signal': datum.signal_desc, 'Avg': format(datum.xbar, ',.4'), 'Limits': format(datum.lnpl, ',.4') + ' to ' + format(datum.unpl, ',.4')} : null"
}
}
}
}
]
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"value": 0},
"y": {"signal": "mrTop - 8"},
"text": {"value": "Moving Range"},
"fontSize": {"value": 11},
"fontWeight": {"value": 600},
"fill": {"value": "#6b7280"},
"opacity": {"signal": "showMr ? 1 : 0"}
}
}
},
{
"type": "group",
"name": "mrChart",
"encode": {
"update": {
"x": {"value": 0},
"y": {"signal": "mrTop"},
"width": {"signal": "plotW"},
"height": {"signal": "mrH"},
"opacity": {"signal": "showMr ? 1 : 0"}
}
},
"scales": [
{
"name": "ymr",
"type": "linear",
"nice": true,
"zero": true,
"domain": {"data": "source", "fields": ["mr", "mr_url"]},
"range": [{"signal": "mrH"}, 6]
}
],
"axes": [
{"orient": "left", "scale": "ymr", "tickCount": 3}
],
"marks": [
{
"type": "rule",
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"value": 0},
"x2": {"signal": "plotW"},
"y": {"scale": "ymr", "field": "mr_url"},
"stroke": {"value": "#9ba1a6"},
"strokeDash": {"value": [4, 4]},
"opacity": {"signal": "showMr ? 1 : 0"}
}
}
},
{
"type": "rule",
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"value": 0},
"x2": {"signal": "plotW"},
"y": {"scale": "ymr", "field": "mrbar"},
"stroke": {"value": "#e5484d"},
"strokeDash": {"value": [6, 4]},
"opacity": {"signal": "showMr ? 1 : 0"}
}
}
},
{
"type": "text",
"interactive": false,
"from": {"data": "stats"},
"encode": {
"update": {
"x": {"signal": "plotW + 8"},
"y": {"scale": "ymr", "field": "mr_url"},
"baseline": {"value": "middle"},
"text": {"signal": "'Range limit ' + format(datum.mr_url, ',.4')"},
"fontSize": {"value": 10},
"fill": {"value": "#9ba1a6"},
"opacity": {"signal": "showMr ? 1 : 0"}
}
}
},
{
"type": "rule",
"interactive": false,
"encode": {
"update": {
"x": {"scale": "x", "signal": "hoveredPeriod"},
"y": {"value": 0},
"y2": {"signal": "mrH"},
"stroke": {"value": "#9ba1a6"},
"strokeDash": {"value": [3, 3]},
"opacity": {"signal": "showMr && hoveredPeriod !== null ? 0.7 : 0"}
}
}
},
{
"type": "line",
"from": {"data": "source"},
"encode": {
"update": {
"x": {"scale": "x", "field": "period"},
"y": {"scale": "ymr", "field": "mr"},
"stroke": {"value": @{options.line_color.value}},
"strokeWidth": {"value": 1.2},
"defined": {"signal": "datum.mr != null"},
"opacity": {"signal": "showMr ? 1 : 0"}
}
}
},
{
"type": "symbol",
"name": "mrPoint",
"from": {"data": "source"},
"encode": {
"update": {
"x": {"scale": "x", "field": "period"},
"y": {"scale": "ymr", "field": "mr"},
"fill": {"signal": "datum.mr != null && datum.mr > datum.mr_url ? '#e5484d' : '@{options.line_color.value}'"},
"size": {"signal": "datum.period === hoveredPeriod ? 120 : (datum.mr != null && datum.mr > datum.mr_url ? 80 : 30)"},
"opacity": {"signal": "showMr && datum.mr != null ? 1 : 0"},
"tooltip": {
"signal": "@{options.show_tooltip.value} && datum.mr != null ? {'Period': datum.period, 'Moving Range': format(datum.mr, ',.4'), 'Avg MR': format(datum.mrbar, ',.4'), 'Upper Range Limit': format(datum.mr_url, ',.4')} : null"
}
}
}
}
]
}
],
"config": {
"background": null,
"axis": {
"domain": false,
"ticks": false,
"labelPadding": 10,
"labelColor": "#858B9E",
"labelFont": "Inter",
"labelFontSize": 11,
"titleColor": "#858B9E",
"titleFont": "Inter",
"titleFontSize": 11
},
"axisX": {"labelOverlap": true},
"axisY": {"grid": true, "gridDash": [8, 3], "gridColor": "#F4F6F8"}
}
};;
}
```
## Required fields
A Control Chart expects exactly two fields. Each row is one observation in the time series.
| Field | Label | Type | Role |
|----------|--------|-------------|------|
| `period` | Period | `dimension` | Time or sequence axis; one point per period. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Metric plotted as individual values, with limits derived from its moving range. Sorted ascending (`apply_order: 2`). |
**Data requirements:** Pre-aggregate to one row per period, since the template does not combine duplicate periods; it sorts by `period` and reads each point in order, so the periods must form a clean ordered sequence. The template drops null values before computing the limits. Limits stabilize with more points (the long-run rule needs at least eight consecutive points), so very short series produce weak signals.
**Sample data:**
| period | value |
|---------|-------|
| 2024-01 | 142 |
| 2024-02 | 138 |
| 2024-03 | 151 |
| 2024-04 | 147 |
| 2024-05 | 139 |
| 2024-06 | 205 |
| 2024-07 | 144 |
| 2024-08 | 149 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-------------|--------|
| `line_color` | `#255dd4` | Color of the value line and routine (unflagged) points. Flagged points keep their severity colors. |
| `show_mr_chart` | `true` | Shows or hides the companion Moving Range panel below the main chart. |
| `show_tooltip` | `true` | Turns the hover tooltip on both panels on or off. |
## Known limitations
- **Needs an ordered time series.** The template sorts by `period` and reads points in sequence, so the data must have one row per period in a meaningful order. Flat or unordered category data does not produce valid limits.
- **Signals are weak on short series.** Limits come from the average moving range, and the long-run rule needs at least eight consecutive points, so very short series detect little.
- **One metric at a time.** The chart plots a single value field against a single period field; it cannot overlay or compare multiple series.
---
## Gauge Chart
A gauge chart visualizes a metric against a threshold, to monitor progress or benchmark against a target.
- **Good for:** showing a single metric against its maximum, monitoring progress toward a goal, at-a-glance KPI panels with a benchmark.
- **Not great for:** comparing many categories at once (use a bar or bullet chart), trends over time, or part-to-whole composition.
## Syntax
Two variants are available:
- **Simple Gauge Chart**: value against a maximum.
- **Gauge Chart with Target**: adds a target marker to benchmark against a goal.
Use one of the following AML definitions to add the Gauge Chart to your custom chart library.
### Simple Gauge Chart
```aml
CustomChartDef gauge_chart {
label: 'Gauge Chart'
description: 'To show a single metric as a needle on a gauge against its maximum, making progress easy to read at a glance.'
fields {
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 1
direction: 'desc'
}
}
field max_value {
label: 'Max Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option background_color {
type: "color-picker"
label: "Background Color"
default_value: "#cbd1d6"
}
option value_color {
type: "color-picker"
label: "Value Color"
default_value: "black"
}
option fillColor {
type: "color-picker"
label: "Fill Color"
default_value: "#3b60d2"
}
option needle_color {
type: "color-picker"
label: "Needle Color"
default_value: "black"
}
option needleScale {
label: 'Needle Scaling Factor'
type: 'number-input'
default_value: 0.8
}
option showLabels {
label: 'Show Labels'
type: 'toggle'
default_value: true
}
option showTicks {
label: 'Show Ticks'
type: 'toggle'
default_value: true
}
option tickGaps {
label: 'Tick Gaps'
type: 'number-input'
default_value: 0.25
}
}
template: @vgl {
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"padding": {"left": 0, "top": 0, "right": 0, "bottom": 0},
"autosize": "fit",
"view": {
"stroke": "transparent"
},
"font": "Inter",
"text": {"fontWeight": 600}
},
"params": [
{"name": "centerX", "expr": "width/2"},
{"name": "centerY", "expr": "height/2 + outerRadius/2"},
{"name": "outerRadius", "expr": "radiusRef"},
{"name": "radiusRef", "expr": "min(width/2, height/2)"},
{"name": "innerRadius", "expr": "outerRadius - outerRadius * 0.25"},
{"name": "fontFactor", "expr": "radiusRef/5"},
{"name": "backgroundColor", "value": @{options.background_color.value}},
{"name": "fillColor", "value": @{options.fillColor.value}},
{"name": "needleColor", "value": @{options.needle_color.value}},
{"name": "needleSize", "expr": "innerRadius * @{options.needleScale.value}"}
],
"data": {
"values": @{values}
},
"transform": [
{"calculate": "datum['@{fields.value.name}'] / datum['@{fields.max_value.name}']", "as": "percentage"},
{"calculate": "(datum.percentage) * (PI) + (-PI/2)", "as": "arcValue"}
],
"layer": [
// Base Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "PI/2"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "backgroundColor"},
"stroke": {"expr": "backgroundColor"},
"strokeWidth": 1,
}
},
// Value Gauge
{
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "datum.arcValue"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "fillColor"}
}
},
// Label
{
"transform": [
{"calculate": "[0, datum['@{fields.max_value.name}']]", "as": "label"},
{"flatten": ["label"]},
{"calculate": "datum.label === 0 ? 0 : 1", "as": "angle"}
],
"encoding": {
"text": {
"field": "label",
"format": @{fields.max_value.format},
"formatType": "holisticsFormat"
},
"theta": {
"field": "angle",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor/2"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"radius": {"expr": "outerRadius*1.05"},
"align": {"expr": "datum.angle === 0 ? 'right' : 'left'"},
"baseline": "alphabetic",
"opacity": {"expr": "@{options.showLabels.value} ? 1 : 0"}
}
},
// Main Value
{
"transform": [
{"calculate": "datum['@{fields.value.name}'] + ' (' + format(datum.percentage, '.0%') + ')'", "as": "mainValue"}
],
"encoding": {
"text": {
"field": "mainValue"
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY + fontFactor*1.2"},
"baseline": "alphabetic",
"align": "center",
"color": @{options.value_color.value}
}
},
// Needle
{
"encoding": {
"angle": {
"field": "percentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [-90, 90]}
}
},
"mark": {
"type": "point",
"shape": {
"expr": "'M 5 -2 A 7 7 0 0 1 -10 -7 L 0 -' + toString(needleSize) + ' Z'"
},
"size": 4,
"opacity": 1,
"fill": {"expr": "needleColor"},
"stroke": {"expr": "needleColor"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"}
}
},
// Ticks
{
"data": {
"sequence": {
"as": "ticks",
"start": 0,
"stop": 1.01,
"step": @{options.tickGaps.value}
}
},
"encoding": {
"theta": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
},
"theta2": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "arc",
"outerRadius": {"expr": "outerRadius"},
"innerRadius": {"expr": "innerRadius"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"stroke": {"expr": "needleColor"},
"opacity": {"expr": "@{options.showTicks.value} ? 1 : 0"}
}
}
]
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field value {
type: "measure"
label: "Value"
}
field max_value {
type: "measure"
label: "Max Value"
}
}
options {
option background_color {
type: "color-picker"
label: "Background Color"
default_value: "#cbd1d6"
}
option value_color {
type: "color-picker"
label: "Value Color"
default_value: "black"
}
option fillColor {
type: "color-picker"
label: "Fill Color"
default_value: "#3b60d2"
}
option needle_color {
type: "color-picker"
label: "Needle Color"
default_value: "black"
}
option needleScale {
label: 'Needle Scaling Factor'
type: 'number-input'
default_value: 0.8
}
option showLabels {
label: 'Show Labels'
type: 'toggle'
default_value: true
}
option showTicks {
label: 'Show Ticks'
type: 'toggle'
default_value: true
}
option tickGaps {
label: 'Tick Gaps'
type: 'number-input'
default_value: 0.25
}
}
template: @vgl {
"config": {
"padding": {"left": 0, "top": 0, "right": 0, "bottom": 0},
"autosize": "fit",
"view": {
"stroke": "transparent"
},
"font": "Inter"
},
"params": [
{"name": "centerX", "expr": "width/2"},
{"name": "centerY", "expr": "height/2 + outerRadius/2"},
{"name": "outerRadius", "expr": "radiusRef"},
{"name": "radiusRef", "expr": "min(width/2, height/2)"},
{"name": "innerRadius", "expr": "outerRadius - outerRadius * 0.25"},
{"name": "fontFactor", "expr": "radiusRef/5"},
{"name": "backgroundColor", "value": @{options.background_color.value}},
{"name": "fillColor", "value": @{options.fillColor.value}},
{"name": "needleColor", "value": @{options.needle_color.value}},
{"name": "needleSize", "expr": "innerRadius * @{options.needleScale.value}"}
],
"data": {
"values": @{values}
},
"transform": [
{"calculate": "datum['@{fields.value.name}'] / datum['@{fields.max_value.name}']", "as": "percentage"},
{"calculate": "(datum.percentage) * (PI) + (-PI/2)", "as": "arcValue"}
],
"layer": [
// Base Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "PI/2"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "backgroundColor"},
"stroke": {"expr": "backgroundColor"},
"strokeWidth": 1,
}
},
// Value Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "datum.arcValue"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "fillColor"}
}
},
// Label
{
"transform": [
{"calculate": "[0, datum['@{fields.max_value.name}']]", "as": "label"},
{"flatten": ["label"]},
{"calculate": "datum.label === 0 ? 0 : 1", "as": "angle"}
],
"encoding": {
"text": {
"field": "label",
"format": @{fields.max_value.format},
"formatType": "holisticsFormat"
},
"theta": {
"field": "angle",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor/2"},
"fontWeight": 600,
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"radius": {"expr": "outerRadius*1.05"},
"align": {"expr": "datum.angle === 0 ? 'right' : 'left'"},
"baseline": "alphabetic",
"opacity": {"expr": "@{options.showLabels.value} ? 1 : 0"}
}
},
// Main Value
{
"transform": [
{"calculate": "datum['@{fields.value.name}'] + ' (' + format(datum.percentage, '.0%') + ')'", "as": "mainValue"}
],
"encoding": {
"text": {
"field": "mainValue"
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor"},
"fontWeight": 600,
"x": {"expr": "centerX"},
"y": {"expr": "centerY + fontFactor*1.2"},
"baseline": "alphabetic",
"align": "center",
"color": @{options.value_color.value}
}
},
// Needle
{
"encoding": {
"angle": {
"field": "percentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [-90, 90]}
}
},
"mark": {
"type": "point",
"shape": {
"expr": "'M 5 -2 A 7 7 0 0 1 -10 -7 L 0 -' + toString(needleSize) + ' Z'"
},
"size": 4,
"opacity": 1,
"fill": {"expr": "needleColor"},
"stroke": {"expr": "needleColor"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"}
}
},
// Ticks
{
"data": {
"sequence": {
"as": "ticks",
"start": 0,
"stop": 1.01,
"step": @{options.tickGaps.value}
}
},
"encoding": {
"theta": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
},
"theta2": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "arc",
"outerRadius": {"expr": "outerRadius"},
"innerRadius": {"expr": "innerRadius"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"stroke": {"expr": "needleColor"},
"opacity": {"expr": "@{options.showTicks.value} ? 1 : 0"}
}
}
]
};;
}
```
### Gauge Chart with Target
```aml
CustomChartDef gauge_chart_with_target {
label: 'Gauge Chart with Target'
description: 'To show a metric as a needle on a gauge against its maximum, with a target marker to benchmark progress toward a goal.'
fields {
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 1
direction: 'desc'
}
}
field max_value {
label: 'Max Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
field target {
label: 'Target'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
option background_color {
type: "color-picker"
label: "Background Color"
default_value: "#cbd1d6"
}
option fillColor {
type: "color-picker"
label: "Fill Color"
default_value: "#3b60d2"
}
option value_color {
type: "color-picker"
label: "Value Color"
default_value: "black"
}
option needle_color {
type: "color-picker"
label: "Needle Color"
default_value: "black"
}
option needleScale {
label: 'Needle Scaling Factor'
type: 'number-input'
default_value: 0.8
}
option showLabels {
label: 'Show Labels'
type: 'toggle'
default_value: false
}
option showTicks {
label: 'Show Ticks'
type: 'toggle'
default_value: false
}
option tickGaps {
label: 'Tick Gaps'
type: 'number-input'
default_value: 0.25
}
option targetColor {
type: "color-picker"
label: "Target Color"
default_value: "#5fc93c"
}
}
template: @vgl {
"config": {
"padding": {"left": 0, "top": 0, "right": 0, "bottom": 0},
"autosize": "fit",
"view": {
"stroke": "transparent"
},
"font": "Inter",
"text": {"fontWeight": 600}
},
"params": [
{"name": "centerX", "expr": "width/2"},
{"name": "centerY", "expr": "height/2 + outerRadius/2"},
{"name": "outerRadius", "expr": "radiusRef"},
{"name": "radiusRef", "expr": "min(width/2, height/2)"},
{"name": "innerRadius", "expr": "outerRadius - outerRadius * 0.25"},
{"name": "fontFactor", "expr": "radiusRef/5"},
{"name": "backgroundColor", "value": @{options.background_color.value}},
{"name": "fillColor", "value": @{options.fillColor.value}},
{"name": "needleColor", "value": @{options.needle_color.value}},
{"name": "needleSize", "expr": "innerRadius * @{options.needleScale.value}"},
{"name": "targetColor", "value": @{options.targetColor.value}}
],
"data": {
"values": @{values}
},
"transform": [
{"calculate": "datum['@{fields.value.name}'] / datum['@{fields.max_value.name}']", "as": "percentage"},
{"calculate": "(datum.percentage) * (PI) + (-PI/2)", "as": "arcValue"},
{"calculate": "datum['@{fields.target.name}'] / datum['@{fields.max_value.name}']", "as": "targetPercentage"}
],
"encoding": {
"tooltip": [
{"field": @{fields.value.name}, "format": @{fields.value.format}, "formatType": "holisticsFormat"},
{"field": @{fields.target.name}, "format": @{fields.target.format}, "formatType": "holisticsFormat"},
{"field": @{fields.max_value.name}, "format": @{fields.max_value.format}, "formatType": "holisticsFormat"}
]
},
"layer": [
// Base Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "PI/2"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "backgroundColor"},
"stroke": {"expr": "backgroundColor"},
"strokeWidth": 1,
}
},
// Value Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "datum.arcValue"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "fillColor"},
}
},
// Label
{
"transform": [
{"calculate": "[0, datum['@{fields.max_value.name}']]", "as": "label"},
{"flatten": ["label"]},
{"calculate": "datum.label === 0 ? 0 : 1", "as": "angle"}
],
"encoding": {
"text": {
"field": "label",
"format": @{fields.max_value.format},
"formatType": "holisticsFormat"
},
"theta": {
"field": "angle",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor/2"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"radius": {"expr": "outerRadius*1.05"},
"align": {"expr": "datum.angle === 0 ? 'right' : 'left'"},
"baseline": "alphabetic",
"opacity": {"expr": "@{options.showLabels.value} ? 1 : 0"}
}
},
// Main Value
{
"transform": [
{"calculate": "datum['@{fields.value.name}'] + ' (' + format(datum.percentage, '.0%') + ')'", "as": "mainValue"},
{
"calculate": "format(datum['@{fields.value.name}'] - datum['@{fields.target.name}'], ',.0f') + ' (' + format(datum['@{fields.value.name}']/datum['@{fields.target.name}'] - 1, '.0%') + ')'",
"as": "reference"
}
],
"encoding": {
"text": {
"field": "mainValue"
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY + fontFactor*1.2"},
"baseline": "alphabetic",
"align": "center",
"color": @{options.value_color.value}
}
},
// Needle
{
"encoding": {
"angle": {
"field": "percentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [-90, 90]}
}
},
"mark": {
"type": "point",
"shape": {
"expr": "'M 5 -2 A 7 7 0 0 1 -10 -7 L 0 -' + toString(needleSize) + ' Z'"
},
"size": 4,
"opacity": 1,
"fill": {"expr": "needleColor"},
"stroke": {"expr": "needleColor"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"}
}
},
// Ticks
{
"data": {
"sequence": {
"as": "ticks",
"start": 0,
"stop": 1.01,
"step": @{options.tickGaps.value}
}
},
"encoding": {
"theta": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
},
"theta2": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "arc",
"outerRadius": {"expr": "outerRadius"},
"innerRadius": {"expr": "innerRadius"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"stroke": {"expr": "needleColor"},
"opacity": {"expr": "@{options.showTicks.value} ? 1 : 0"}
}
},
// Target Gauge
{
"encoding": {
"theta": {
"field": "targetPercentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
},
"theta2": {
"field": "targetPercentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "arc",
"outerRadius": {"expr": "outerRadius*1.05"},
"innerRadius": {"expr": "innerRadius*0.95"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"stroke": {"expr": "targetColor"},
"strokeWidth": 2
}
}
]
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field value {
type: "measure"
label: "Value"
}
field max_value {
type: "measure"
label: "Max Value"
}
field target {
type: "measure"
label: "Target"
}
}
options {
option background_color {
type: "color-picker"
label: "Background Color"
default_value: "#cbd1d6"
}
option fillColor {
type: "color-picker"
label: "Fill Color"
default_value: "#3b60d2"
}
option value_color {
type: "color-picker"
label: "Value Color"
default_value: "black"
}
option needle_color {
type: "color-picker"
label: "Needle Color"
default_value: "black"
}
option needleScale {
label: 'Needle Scaling Factor'
type: 'number-input'
default_value: 0.8
}
option showLabels {
label: 'Show Labels'
type: 'toggle'
default_value: false
}
option showTicks {
label: 'Show Ticks'
type: 'toggle'
default_value: false
}
option tickGaps {
label: 'Tick Gaps'
type: 'number-input'
default_value: 0.25
}
option targetColor {
type: "color-picker"
label: "Target Color"
default_value: "#5fc93c"
}
}
template: @vgl {
"config": {
"padding": {"left": 0, "top": 0, "right": 0, "bottom": 0},
"autosize": "fit",
"view": {
"stroke": "transparent"
},
"font": "Inter"
},
"params": [
{"name": "centerX", "expr": "width/2"},
{"name": "centerY", "expr": "height/2 + outerRadius/2"},
{"name": "outerRadius", "expr": "radiusRef"},
{"name": "radiusRef", "expr": "min(width/2, height/2)"},
{"name": "innerRadius", "expr": "outerRadius - outerRadius * 0.25"},
{"name": "fontFactor", "expr": "radiusRef/5"},
{"name": "backgroundColor", "value": @{options.background_color.value}},
{"name": "fillColor", "value": @{options.fillColor.value}},
{"name": "needleColor", "value": @{options.needle_color.value}},
{"name": "needleSize", "expr": "innerRadius * @{options.needleScale.value}"},
{"name": "targetColor", "value": @{options.targetColor.value}}
],
"data": {
"values": @{values}
},
"transform": [
{"calculate": "datum['@{fields.value.name}'] / datum['@{fields.max_value.name}']", "as": "percentage"},
{"calculate": "(datum.percentage) * (PI) + (-PI/2)", "as": "arcValue"},
{"calculate": "datum['@{fields.target.name}'] / datum['@{fields.max_value.name}']", "as": "targetPercentage"}
],
"encoding": {
"tooltip": [
{"field": @{fields.value.name}, "format": @{fields.value.format}, "formatType": "holisticsFormat"},
{"field": @{fields.target.name}, "format": @{fields.target.format}, "formatType": "holisticsFormat"},
{"field": @{fields.max_value.name}, "format": @{fields.max_value.format}, "formatType": "holisticsFormat"}
]
},
"layer": [
// Base Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "PI/2"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "backgroundColor"},
"stroke": {"expr": "backgroundColor"},
"strokeWidth": 1,
}
},
// Value Gauge
{
"mark": {
"type": "arc",
"name": "gauge",
"theta": {"expr": "-PI/2"},
"theta2": {"expr": "datum.arcValue"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"innerRadius": {"expr": "innerRadius"},
"outerRadius": {"expr": "outerRadius"},
"fill": {"expr": "fillColor"},
}
},
// Label
{
"transform": [
{"calculate": "[0, datum['@{fields.max_value.name}']]", "as": "label"},
{"flatten": ["label"]},
{"calculate": "datum.label === 0 ? 0 : 1", "as": "angle"}
],
"encoding": {
"text": {
"field": "label",
"format": @{fields.max_value.format},
"formatType": "holisticsFormat"
},
"theta": {
"field": "angle",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor/2"},
"fontWeight": 600,
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"radius": {"expr": "outerRadius*1.05"},
"align": {"expr": "datum.angle === 0 ? 'right' : 'left'"},
"baseline": "alphabetic",
"opacity": {"expr": "@{options.showLabels.value} ? 1 : 0"}
}
},
// Main Value
{
"transform": [
{"calculate": "datum['@{fields.value.name}'] + ' (' + format(datum.percentage, '.0%') + ')'", "as": "mainValue"},
{
"calculate": "format(datum['@{fields.value.name}'] - datum['@{fields.target.name}'], ',.0f') + ' (' + format(datum['@{fields.value.name}']/datum['@{fields.target.name}'] - 1, '.0%') + ')'",
"as": "reference"
}
],
"encoding": {
"text": {
"field": "mainValue"
}
},
"mark": {
"type": "text",
"fontSize": {"expr": "fontFactor"},
"fontWeight": 600,
"x": {"expr": "centerX"},
"y": {"expr": "centerY + fontFactor*1.2"},
"baseline": "alphabetic",
"align": "center",
"color": @{options.value_color.value}
}
},
// Needle
{
"encoding": {
"angle": {
"field": "percentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [-90, 90]}
}
},
"mark": {
"type": "point",
"shape": {
"expr": "'M 5 -2 A 7 7 0 0 1 -10 -7 L 0 -' + toString(needleSize) + ' Z'"
},
"size": 4,
"opacity": 1,
"fill": {"expr": "needleColor"},
"stroke": {"expr": "needleColor"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"}
}
},
// Ticks
{
"data": {
"sequence": {
"as": "ticks",
"start": 0,
"stop": 1.01,
"step": @{options.tickGaps.value}
}
},
"encoding": {
"theta": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
},
"theta2": {
"field": "ticks",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "arc",
"outerRadius": {"expr": "outerRadius"},
"innerRadius": {"expr": "innerRadius"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"stroke": {"expr": "needleColor"},
"opacity": {"expr": "@{options.showTicks.value} ? 1 : 0"}
}
},
// Target Gauge
{
"encoding": {
"theta": {
"field": "targetPercentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
},
"theta2": {
"field": "targetPercentage",
"type": "quantitative",
"scale": {"domain": [0, 1], "range": [{"expr": "-PI/2"}, {"expr": "PI/2"}]}
}
},
"mark": {
"type": "arc",
"outerRadius": {"expr": "outerRadius*1.05"},
"innerRadius": {"expr": "innerRadius*0.95"},
"x": {"expr": "centerX"},
"y": {"expr": "centerY"},
"stroke": {"expr": "targetColor"},
"strokeWidth": 2
}
}
]
};;
}
```
## Required fields
Both variants take a single row of measures. The Simple Gauge Chart expects two fields; the Gauge Chart with Target expects three. The template draws the value as a needle and an arc filling toward the maximum.
**Simple Gauge Chart:**
| Field | Label | Type | Role |
|-------------|-----------|-----------|------|
| `value` | Value | `measure` | Current metric, drawn as the filled arc and needle. Sorted descending (`apply_order: 1`). |
| `max_value` | Max Value | `measure` | Top of the gauge range (the 100% end). Sorted descending (`apply_order: 2`). |
**Gauge Chart with Target:**
| Field | Label | Type | Role |
|-------------|-----------|-----------|------|
| `value` | Value | `measure` | Current metric, drawn as the filled arc and needle. Sorted descending (`apply_order: 1`). |
| `max_value` | Max Value | `measure` | Top of the gauge range (the 100% end). Sorted descending (`apply_order: 2`). |
| `target` | Target | `measure` | Goal value, drawn as a colored marker on the arc. Sorted descending (`apply_order: 3`). |
**Data requirements:** Provide a single row. The template reads one record and computes `value / max_value` as the fill percentage, so pre-aggregate to one value per measure. Keep `max_value` greater than zero, since it is the divisor for the gauge percentage.
**Sample data:**
Simple Gauge Chart:
| value | max_value |
|-------|-----------|
| 720 | 1000 |
Gauge Chart with Target:
| value | max_value | target |
|-------|-----------|--------|
| 720 | 1000 | 850 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
Both variants share these options:
| Option | Default | Effect |
|--------------------|-----------|--------|
| `background_color` | `#cbd1d6` | Color of the unfilled background arc. |
| `value_color` | `black` | Color of the main value label text. |
| `fillColor` | `#3b60d2` | Color of the filled arc up to the current value. |
| `needle_color` | `black` | Color of the needle (and tick marks). |
| `needleScale` | `0.8` | Needle length as a fraction of the inner radius. |
| `showLabels` | `true` | Toggles the 0 and max-value end labels. Defaults to `false` in the Target variant. |
| `showTicks` | `true` | Toggles the evenly spaced tick marks. Defaults to `false` in the Target variant. |
| `tickGaps` | `0.25` | Spacing between ticks as a fraction of the range (smaller means more ticks). |
The Gauge Chart with Target adds one more option:
| Option | Default | Effect |
|---------------|-----------|--------|
| `targetColor` | `#5fc93c` | Color of the target marker on the arc. |
## Known limitations
- **Single value only.** Each gauge renders one record. To compare categories, use a separate gauge per value or switch to a bar or bullet chart.
- **`max_value` must be greater than zero.** It is the divisor for the fill percentage, so a zero or missing maximum produces an invalid gauge.
- **Half-circle range fixed at 0 to max.** The arc always runs from 0 to `max_value` across a 180-degree sweep. Custom minimums or full-circle gauges require editing the template.
---
## Density Contour Plot
A density contour plot shows where observations concentrate across two numeric metrics, drawing a set of nested contour lines per group like a topographic map of each group's density.
- **Good for:** seeing where groups cluster across two numeric metrics, spotting overlap or separation between segments, replacing an overplotted scatter cloud (spend vs frequency by segment, price vs rating by category).
- **Not great for:** a single ungrouped distribution (use a histogram), comparing one numeric value across categories (use a ridgeline chart), or categorical (non-numeric) axes.

## Syntax
Use the following AML definition to add the Density Contour Plot to your custom chart library.
```aml
CustomChartDef density_contour {
label: 'Density Contour Plot'
description: 'To show where observations cluster across two numeric metrics, with per-group 2D density contours over a scatter plot.'
fields {
field x_axis {
label: 'X-axis'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 1
direction: 'asc'
}
}
field y_axis {
label: 'Y-axis'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
field group {
label: 'Group'
type: 'dimension'
sort {
apply_order: 3
direction: 'asc'
}
}
}
options {
option bandwidth {
label: 'Smoothing bandwidth (0 = automatic)'
type: 'number-input'
default_value: 0
}
option contour_levels {
label: 'Number of contour levels'
type: 'select'
options: [2, 3, 4, 5, 6]
default_value: 4
}
option show_points {
label: 'Show scatter points'
type: 'toggle'
default_value: true
}
option show_heatmap {
label: 'Show filled density heatmap'
type: 'toggle'
default_value: false
}
option color_scheme {
label: 'Color scheme'
type: 'select'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 82",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 82" }]
},
{
"name": "height",
"init": "containerSize()[1] - 68",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 68" }]
},
{"name": "bw", "update": "@{options.bandwidth.value} <= 0 ? -1 : @{options.bandwidth.value}"},
{"name": "levels", "update": "@{options.contour_levels.value}"},
{"name": "showHeatmap", "update": "@{options.show_heatmap.value}"},
{
"name": "hovered",
"value": null,
"on": [
{"events": "@legendSymbol:mouseover, @legendLabel:mouseover", "update": "datum.value"},
{"events": "@legendSymbol:mouseout, @legendLabel:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@pointMark:click", "update": "{'@{fields.group.name}': [datum['group']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@pointMark:mouseover", "update": "{'@{fields.group.name}': [datum['group']]}"},
{"events": "@pointMark:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "source",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.x_axis.name}']", "as": "x_value"},
{"type": "formula", "expr": "datum['@{fields.y_axis.name}']", "as": "y_value"},
{"type": "formula", "expr": "datum['@{fields.group.name}']", "as": "group"},
{"type": "filter", "expr": "datum.x_value != null && datum.y_value != null"}
]
},
{
"name": "density",
"source": "source",
"transform": [
{
"type": "kde2d",
"groupby": ["group"],
"size": [{"signal": "width"}, {"signal": "height"}],
"x": {"expr": "scale('x', datum.x_value)"},
"y": {"expr": "scale('y', datum.y_value)"},
"bandwidth": {"signal": "[bw, bw]"},
"counts": true
}
]
},
{
"name": "contours",
"source": "density",
"transform": [
{
"type": "isocontour",
"field": "grid",
"resolve": "shared",
"levels": {"signal": "levels"}
}
]
}
],
"scales": [
{
"name": "x",
"type": "linear",
"round": true,
"nice": true,
"zero": true,
"domain": {"data": "source", "field": "x_value"},
"range": [0, {"signal": "width"}]
},
{
"name": "y",
"type": "linear",
"round": true,
"nice": true,
"zero": true,
"domain": {"data": "source", "field": "y_value"},
"range": [{"signal": "height"}, 0]
},
{
"name": "color",
"type": "ordinal",
"domain": {"data": "source", "field": "group", "sort": true},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"axes": [
{"scale": "x", "orient": "bottom", "tickCount": 5, "title": "@{fields.x_axis.name}"},
{"scale": "y", "orient": "left", "title": "@{fields.y_axis.name}"}
],
"legends": [
{
"stroke": "color",
"title": null,
"encode": {
"symbols": {
"name": "legendSymbol",
"interactive": true,
"update": {
"strokeWidth": {"value": 2},
"opacity": {"signal": "hovered === null || hovered === datum.value ? 1 : 0.2"}
}
},
"labels": {
"name": "legendLabel",
"interactive": true,
"update": {
"opacity": {"signal": "hovered === null || hovered === datum.value ? 1 : 0.3"}
}
}
}
}
],
"marks": [
{
"type": "symbol",
"name": "pointMark",
"from": {"data": "source"},
"encode": {
"update": {
"x": {"scale": "x", "field": "x_value"},
"y": {"scale": "y", "field": "y_value"},
"size": {"value": 14},
"fill": {"scale": "color", "field": "group"},
"fillOpacity": {
"signal": "@{options.show_points.value} ? (hovered === null ? 0.35 : (hovered === datum.group ? 0.6 : 0.06)) : 0"
},
"tooltip": {
"signal": "{'Group': datum.group, '@{fields.x_axis.name}': format(datum.x_value, ','), '@{fields.y_axis.name}': format(datum.y_value, ',')}"
}
}
}
},
{
"type": "image",
"from": {"data": "density"},
"clip": true,
"encode": {
"update": {
"x": {"value": 0},
"y": {"value": 0},
"width": {"signal": "width"},
"height": {"signal": "height"},
"aspect": {"value": false},
"opacity": {"signal": "showHeatmap ? 0.8 : 0"}
}
},
"transform": [
{
"type": "heatmap",
"field": "datum.grid",
"resolve": "shared",
"color": {"expr": "scale('color', datum.datum.group)"}
}
]
},
{
"type": "path",
"clip": true,
"from": {"data": "contours"},
"encode": {
"enter": {
"strokeWidth": {"value": 1.5},
"stroke": {"scale": "color", "field": "group"}
},
"update": {
"strokeOpacity": {
"signal": "hovered === null ? 0.9 : (hovered === datum.group ? 1 : 0.12)"
}
}
},
"transform": [
{"type": "geopath", "field": "datum.contour"}
]
}
],
"config": {
"background": null,
"axis": {
"grid": true,
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domain": false,
"ticks": false,
"labelColor": "#858B9E",
"labelFont": "Inter",
"labelFontSize": 11,
"labelPadding": 10,
"titleColor": "#858B9E",
"titleFont": "Inter",
"titleFontSize": 11
},
"axisY": {"titlePadding": 6},
"legend": {
"orient": "top",
"direction": "horizontal",
"symbolType": "stroke",
"labelColor": "#858B9E",
"labelFont": "Inter",
"labelFontSize": 11
}
}
};;
}
```
## Required fields
A Density Contour Plot expects exactly three fields. Each row of input is one observation, plotted as a point and fed into its group's density estimate.
| Field | Label | Type | Role |
|----------|--------|-------------|------|
| `x_axis` | X-axis | `dimension` | Numeric value on the horizontal axis. Sorted ascending (`apply_order: 1`). |
| `y_axis` | Y-axis | `dimension` | Numeric value on the vertical axis. Sorted ascending (`apply_order: 2`). |
| `group` | Group | `dimension` | Splits observations into groups; one contour set and color per group. Sorted ascending (`apply_order: 3`). |
**Data requirements:** Feed raw observations (one row per record), not pre-aggregated values, since the template estimates each group's density with KDE. Both `x_axis` and `y_axis` must be numeric; the template drops rows where either is null before rendering.
**Sample data:**
| x_axis | y_axis | group |
|--------|--------|-------------|
| 120 | 4.2 | Enterprise |
| 95 | 3.8 | Enterprise |
| 60 | 4.5 | SMB |
| 45 | 3.1 | SMB |
| 80 | 2.9 | Mid-market |
| 110 | 3.6 | Mid-market |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-------------|--------|
| `bandwidth` | `0` | KDE smoothing bandwidth. `0` lets Vega pick automatically; larger values produce smoother, broader contours. |
| `contour_levels` | `4` | Number of nested contour lines drawn per group. |
| `show_points` | `true` | Whether the underlying scatter points are visible. |
| `show_heatmap` | `false` | Whether to draw a filled density heatmap behind the contours. |
| `color_scheme` | `tableau10` | Ordinal color palette applied to groups. |
## Known limitations
- **Both axes must be numeric.** `x_axis` and `y_axis` feed a 2D KDE, so categorical axes will not work. Use a different chart for non-numeric axes.
- **Needs raw rows, not aggregates.** The density estimate runs on individual observations, so pre-aggregated input gives a misleading shape. Feed one row per record.
- **Sparse groups produce unstable contours.** A group with very few observations yields jumpy or empty contours, since KDE needs enough points to estimate density.
---
## Diverging Bar Chart
A diverging bar chart splits positive and negative values around a zero baseline, ideal for variance against budget or plan. The chart sorts categories by value, so the biggest gains and shortfalls sit at opposite ends.
- **Good for:** variance against budget or plan, net sentiment or profit-and-loss by category, any signed measure where direction matters.
- **Not great for:** all-positive measures (a plain bar chart is clearer), part-to-whole composition, or time series.

## Syntax
Use the following AML definition to add the Diverging Bar Chart to your custom chart library.
```aml
CustomChartDef diverging_bar_chart {
label: 'Diverging Bar Chart'
description: 'To split positive and negative values around a zero baseline, ideal for showing variance against budget or plan.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option positive_color {
type: 'color-picker'
label: 'Positive Color'
default_value: '#2cb67f'
}
option negative_color {
type: 'color-picker'
label: 'Negative Color'
default_value: '#e5484d'
}
option show_tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
}
template: @vgl
{
"data": {"values": @{values}},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "fields": [@{fields.dimension.name}], "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "fields": [@{fields.dimension.name}], "on": "mouseover", "clear": "mouseout"}}
],
"mark": {
"type": "bar",
"tooltip": @{options.show_tooltip.value}
},
"encoding": {
"y": {
"field": @{fields.dimension.name},
"type": "nominal",
"sort": "-x"
},
"x": {
"field": @{fields.value.name},
"type": "quantitative",
"axis": {
"format": @{fields.value.format},
"formatType": "holisticsFormat"
}
},
"color": {
"condition": {
"test": "datum['@{fields.value.name}'] >= 0",
"value": @{options.positive_color.value}
},
"value": @{options.negative_color.value}
}
},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": true},
"axisY": {"domain": false, "grid": false},
"bar": {"cornerRadius": 2}
}
}
;;
}
```
## Required fields
A Diverging Bar Chart expects exactly two fields. Each row is one horizontal bar.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category for each bar (y-axis). The template re-sorts bars by value at render time, so the longest bars sit at the ends. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Bar length and direction. Negative values extend left of the zero baseline, positive values extend right. Sorted descending (`apply_order: 2`). |
**Data requirements:** Pre-aggregate to one row per `dimension`; the template plots values as-is and does not combine duplicates. Use a signed measure with both positive and negative values, otherwise the bars all point the same way and there is nothing to diverge. The template assigns color by sign (`value >= 0` gets the positive color, the rest get the negative color).
**Sample data:**
| dimension | value |
|-------------|-------|
| North | 12000 |
| South | 4500 |
| East | -3200 |
| West | -8700 |
| Central | 1500 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-----------|--------|
| `positive_color` | `#2cb67f` | Color of bars with a value at or above zero. |
| `negative_color` | `#e5484d` | Color of bars with a value below zero. |
| `show_tooltip` | `true` | Toggles hover tooltips on the bars. |
## Known limitations
- **Needs signed values to diverge.** Color and direction are driven by the sign of `value`. With all-positive or all-negative data every bar points the same way, so a plain bar chart reads more clearly.
- **Color carries no other meaning.** The two colors only mark positive versus negative, so you cannot encode a separate series or category through color without editing the template.
- **Bar order follows value, not your sort.** Bars always re-sort by value (largest at the ends), so the field's own sort order does not control vertical placement.
---
## Error Bar
An error bar visualizes the uncertainty or degree of error in a reported measurement.
- **Good for:** showing the spread or confidence around a mean per category, comparing variability across groups, summarizing repeated measurements.
- **Not great for:** a single value with no spread (use a KPI or gauge), trends over time, or part-to-whole composition.

## Syntax
Use the following AML definition to add the Error Bar to your custom chart library.
```aml
CustomChartDef error_bar {
label: 'Error Bar'
description: 'To visualize the uncertainty or degree of error in a reported measurement across categories.'
fields {
field value {
label: 'Value'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 1
direction: 'asc'
}
}
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option point_color {
type: 'color-picker'
label: 'Point Color'
default_value: '#255DD4'
}
option extent {
type: 'select'
label: 'Error extent'
options: ['ci', 'stderr', 'stdev', 'iqr']
default_value: 'ci'
}
}
template: @vgl {
"data": {
"values": @{values}
},
"encoding": {
"y": {
"field": @{fields.dimension.name},
"type": "ordinal"
}
},
"layer": [
{
"mark": {
"type": "errorbar",
"extent": @{options.extent.value},
"color": "#9CA3AF",
"thickness": 1.5,
"ticks": true
},
"encoding": {
"x": {
"field": @{fields.value.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}
},
"tooltip": [
{"field": @{fields.dimension.name}, "type": "nominal", "title": "Category"}
]
}
},
{
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"mark": {
"type": "point",
"filled": true,
"size": 80,
"color": @{options.point_color.value}
},
"encoding": {
"x": {
"field": @{fields.value.name},
"type": "quantitative",
"aggregate": "mean",
"scale": {"zero": false}
},
"tooltip": [
{"field": @{fields.dimension.name}, "type": "nominal", "title": "Category"},
{"field": @{fields.value.name}, "type": "quantitative", "aggregate": "mean", "title": "Mean", "format": @{fields.value.format}, "formatType": "holisticsFormat"}
]
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": true},
"axisY": {"domain": false, "grid": false}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field point {
type: "dimension"
label: "Points"
}
field variety {
type: "dimension"
label: "Variety"
}
}
template: @vgl
{
"data": {
"values": @{values}
},
"layer": [
{
"mark": {
"type": "point",
"filled": true
},
"encoding": {
"x": {
"type": "quantitative",
"field": @{fields.point.name},
"scale": {
"zero": false
},
"title": "Barley Yield",
"aggregate": "mean"
},
"color": {
"value": "black"
}
}
},
{
"mark": {
"type": "errorbar",
"extent": "ci"
},
"encoding": {
"x": {
"type": "quantitative",
"field": @{fields.point.name},
"title": "Barley Yield"
}
}
}
],
"encoding": {
"y": {
"type": "ordinal",
"field": @{fields.variety.name}
}
}
}
;;;
}
```
## Required fields
An Error Bar expects exactly two fields. Each input row is one observation, and the template groups observations by category to compute the mean and spread.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `value` | Value | `dimension` | Numeric observation on the x axis (`data_type: 'number'`). The mean sets the point, and the chosen extent sets the bar. Sorted ascending (`apply_order: 1`). |
| `dimension` | Dimension | `dimension` | Category on the y axis (one error bar per value). Sorted ascending (`apply_order: 2`). |
**Data requirements:** Do not pre-aggregate. The template computes the mean and the error extent from the raw rows, so each category needs multiple observations for a meaningful spread.
**Sample data:**
| value | dimension |
|-------|-----------|
| 42 | Region A |
| 47 | Region A |
| 39 | Region A |
| 51 | Region B |
| 55 | Region B |
| 49 | Region B |
| 33 | Region C |
| 38 | Region C |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|---------------|-----------|--------|
| `point_color` | `#255DD4` | Fill color of the mean point marker. |
| `extent` | `ci` | How the template computes the error bar length (confidence interval, standard error, standard deviation, or interquartile range). |
## Known limitations
- **Needs multiple rows per category.** The bar reflects spread across observations, so a single row per category produces a point with no visible error bar.
- **The center point is always the mean of `value`.** Showing a median or another statistic requires editing the template.
- **One dimension only.** The chart plots one category axis. Comparing a second grouping (for example, by color) requires editing the template.
---
## Faceted Sparkline
A faceted sparkline shows the same metric's trend for many categories side by side, one compact panel per category. Because each panel scales independently, you compare the shape of each trend rather than absolute values.
- **Good for:** comparing the shape of a trend across many categories (revenue per region, sign-ups per channel, a KPI across teams), spotting which segments are growing or declining, replacing a multi-line chart that has turned into spaghetti.
- **Not great for:** comparing absolute values across panels (each panel scales independently), a single category (a plain line chart is simpler), or part-to-whole composition (use a sunburst or treemap chart).

## Syntax
Use the following AML definition to add the Faceted Sparkline to your custom chart library.
```aml
CustomChartDef faceted_sparkline {
label: 'Faceted Sparkline'
description: 'To show a compact line trend split into small multiples by category, with optional series coloring and tooltip control.'
fields {
field date {
label: 'Date'
type: 'dimension'
data_type: 'date'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'asc'
}
}
field facet {
label: 'Facet'
type: 'dimension'
sort {
apply_order: 3
direction: 'asc'
}
}
field series {
label: 'Series'
type: 'dimension'
sort {
apply_order: 4
direction: 'asc'
}
}
}
options {
option show_tooltip {
label: 'Show tooltip'
type: 'toggle'
default_value: true
}
option facet_columns {
label: 'Facet columns'
type: 'number-input'
default_value: 4
}
option color_scheme {
label: 'Color scheme'
type: 'select'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0]",
"on": [{ "events": "window:resize", "update": "containerSize()[0]" }]
},
{
"name": "height",
"init": "containerSize()[1]",
"on": [{ "events": "window:resize", "update": "containerSize()[1]" }]
},
{"name": "columns", "update": "max(1, @{options.facet_columns.value})"},
{"name": "rows", "update": "max(1, ceil(length(data('facets')) / columns))"},
{"name": "cellW", "update": "width / columns"},
{"name": "cellH", "update": "height / rows"},
{"name": "headerH", "value": 22},
{"name": "plotW", "update": "max(10, cellW - 16)"},
{"name": "plotH", "update": "max(10, cellH - headerH - 12)"},
{
"name": "cursorX",
"value": null,
"on": [
{"events": "@hoverRect:mousemove, @sparkline:mousemove", "update": "clamp(x(group()), 0, plotW)"},
{"events": "@hoverRect:mouseout", "update": "null"}
]
},
{"name": "hoverDate", "update": "cursorX === null ? null : invert('x', cursorX)"},
{
"name": "hoveredSeries",
"value": null,
"on": [
{"events": "@sparkline:mouseover", "update": "datum.series_value"},
{"events": "@sparkline:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@sparkline:click", "update": "{'@{fields.series.name}': [datum['series_value']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@sparkline:mouseover", "update": "{'@{fields.series.name}': [datum['series_value']]}"},
{"events": "@sparkline:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "source",
"values": @{values},
"transform": [
{"type": "formula", "expr": "toDate(datum['@{fields.date.name}'])", "as": "date_value"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "formula", "expr": "datum['@{fields.facet.name}']", "as": "facet_value"},
{"type": "formula", "expr": "datum['@{fields.series.name}']", "as": "series_value"},
{"type": "filter", "expr": "datum.amount != null"},
{"type": "collect", "sort": {"field": "date_value"}}
]
},
{
"name": "facets",
"source": "source",
"transform": [
{"type": "aggregate", "groupby": ["facet_value"]},
{"type": "collect", "sort": {"field": "facet_value"}},
{"type": "window", "ops": ["row_number"], "as": ["index"]},
{"type": "formula", "expr": "(datum.index - 1) % columns", "as": "col"},
{"type": "formula", "expr": "floor((datum.index - 1) / columns)", "as": "row"}
]
},
{
"name": "plot",
"source": "source",
"transform": [
{
"type": "lookup",
"from": "facets",
"key": "facet_value",
"fields": ["facet_value"],
"values": ["col", "row"],
"as": ["col", "row"]
}
]
},
{
"name": "hover_points",
"source": "plot",
"transform": [
{"type": "filter", "expr": "hoverDate != null"},
{"type": "formula", "expr": "abs(datum.date_value - time(hoverDate))", "as": "dist"},
{
"type": "joinaggregate",
"ops": ["min"],
"fields": ["dist"],
"as": ["min_dist"],
"groupby": ["facet_value", "series_value"]
},
{"type": "filter", "expr": "datum.dist === datum.min_dist"},
{"type": "collect", "sort": {"field": "series_value"}},
{"type": "window", "ops": ["row_number"], "as": ["sidx"], "groupby": ["facet_value"]}
]
}
],
"scales": [
{
"name": "x",
"type": "time",
"domain": {"data": "plot", "field": "date_value"},
"range": [0, {"signal": "plotW"}]
},
{
"name": "color",
"type": "ordinal",
"domain": {"data": "plot", "field": "series_value"},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"marks": [
{
"type": "group",
"from": {
"facet": {"name": "cell", "data": "plot", "groupby": ["facet_value", "col", "row"]}
},
"encode": {
"update": {
"x": {"signal": "datum.col * cellW + 8"},
"y": {"signal": "datum.row * cellH"},
"width": {"signal": "plotW"},
"height": {"signal": "cellH"}
}
},
"scales": [
{
"name": "yscale",
"type": "linear",
"nice": true,
"domain": {"data": "cell", "field": "amount"},
"range": [{"signal": "headerH + plotH"}, {"signal": "headerH"}]
}
],
"marks": [
{
"type": "rect",
"name": "hoverRect",
"encode": {
"update": {
"x": {"value": 0},
"y": {"value": 0},
"width": {"signal": "plotW"},
"height": {"signal": "cellH"},
"fill": {"value": "transparent"}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"value": 0},
"y": {"value": 12},
"text": {"signal": "parent.facet_value"},
"limit": {"signal": "plotW * 0.4"},
"fontSize": {"value": 12},
"fontWeight": {"value": 600},
"fill": {"value": "#374151"}
}
}
},
{
"type": "rule",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "cursorX === null ? 0 : cursorX"},
"y": {"signal": "headerH"},
"y2": {"signal": "headerH + plotH"},
"stroke": {"value": "#9ba1a6"},
"strokeDash": {"value": [3, 3]},
"opacity": {"signal": "cursorX === null ? 0 : 0.6"}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "plotW"},
"y": {"value": 12},
"align": {"value": "right"},
"text": {"signal": "hoverDate === null ? '' : timeFormat(hoverDate, '%b %d, %Y')"},
"fontSize": {"value": 11},
"fill": {"value": "#6b7280"}
}
}
},
{
"type": "symbol",
"interactive": false,
"from": {"data": "hover_points"},
"encode": {
"update": {
"x": {"scale": "x", "field": "date_value"},
"y": {"scale": "yscale", "field": "amount"},
"fill": {"scale": "color", "field": "series_value"},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1},
"size": {"value": 50},
"opacity": {"signal": "datum.facet_value === parent.facet_value ? 1 : 0"}
}
}
},
{
"type": "text",
"interactive": false,
"from": {"data": "hover_points"},
"encode": {
"update": {
"x": {"signal": "plotW * 0.45 + (datum.sidx - 1) * 60"},
"y": {"value": 12},
"align": {"value": "left"},
"text": {"signal": "format(datum.amount, ',')"},
"fontSize": {"value": 11},
"fontWeight": {"value": 600},
"fill": {"scale": "color", "field": "series_value"},
"opacity": {"signal": "datum.facet_value === parent.facet_value ? 1 : 0"}
}
}
},
{
"type": "group",
"from": {
"facet": {"name": "series_split", "data": "cell", "groupby": "series_value"}
},
"marks": [
{
"type": "line",
"name": "sparkline",
"from": {"data": "series_split"},
"encode": {
"update": {
"x": {"scale": "x", "field": "date_value"},
"y": {"scale": "yscale", "field": "amount"},
"stroke": {"scale": "color", "field": "series_value"},
"strokeWidth": {"signal": "hoveredSeries === datum.series_value ? 2.5 : 1.5"},
"opacity": {"signal": "hoveredSeries === null || hoveredSeries === datum.series_value ? 1 : 0.25"},
"interpolate": {"value": "monotone"},
"tooltip": {
"signal": "@{options.show_tooltip.value} ? {'Facet': datum.facet_value, 'Date': timeFormat(datum.date_value, '%Y-%m-%d'), 'Value': format(datum.amount, ',')} : null"
}
}
}
}
]
}
]
}
]
};;
}
```
## Required fields
A Faceted Sparkline expects exactly four fields. `facet` makes one panel per category, and within each panel `series` draws one line per series.
| Field | Label | Type | Role |
|----------|--------|-------------|------|
| `date` | Date | `dimension` | Time axis (x) within each panel. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Line height (y), scaled per panel. Sorted ascending (`apply_order: 2`). |
| `facet` | Facet | `dimension` | Splits the data into one panel per value. Sorted ascending (`apply_order: 3`). |
| `series` | Series | `dimension` | Draws one colored line per value within each panel. Sorted ascending (`apply_order: 4`). |
**Data requirements:** Pre-aggregate to one row per date, facet, and series combination; the template plots `value` directly without summing. The template drops rows where `value` is null. If every facet has only one series, supply a constant `series` value so each panel still draws a single line.
**Sample data:**
| date | value | facet | series |
|------------|-------|-------|---------|
| 2024-01-01 | 4200 | APAC | Revenue |
| 2024-02-01 | 4600 | APAC | Revenue |
| 2024-03-01 | 5100 | APAC | Revenue |
| 2024-01-01 | 3100 | EMEA | Revenue |
| 2024-02-01 | 2950 | EMEA | Revenue |
| 2024-03-01 | 3300 | EMEA | Revenue |
## Options
Set these options to adjust the layout without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-------------|--------|
| `show_tooltip` | `true` | Toggles the hover tooltip on each line. |
| `facet_columns` | `4` | Number of panels per row; the grid wraps to as many rows as needed. |
| `color_scheme` | `tableau10` | Ordinal color palette applied to the series. |
## Known limitations
- **Panels do not share a y-scale.** Each panel scales independently, so it shows trend shape, not absolute size. Use a single chart with a shared axis when you need to compare magnitudes across categories.
- **Many facets shrink each panel.** Every facet value gets its own panel, so a large number of facets leaves each one too small to read. Filter to the categories you care about or raise `facet_columns`.
- **No dedicated axis labels per panel.** Panels show the trend, a header value, and a hover crosshair, but no full axis ticks. Reach for a regular line chart when exact axis values matter.
---
## Histogram
A histogram groups numerical values into discrete ranges (bins) and uses bars to show how many values fall in each bin. It is a simple yet effective way to grasp the distribution of a numeric variable.
- **Good for:** seeing the distribution of a single numeric variable, spotting skew or outliers, checking how values cluster (order value, response time, age).
- **Not great for:** comparing distributions across categories (use a ridgeline chart), categorical counts (use a bar chart), or two-metric density (use a density contour plot).
## Syntax
Use the following AML definition to add the Histogram to your custom chart library.
```aml
CustomChartDef histogram {
label: 'Histogram'
description: 'To group numerical values into discrete ranges (bins) and use bars to show how many members fall into each bin.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option max_bins {
type: 'number-input'
label: 'Max bins'
default_value: 40
}
}
template: @vgl {
"data": {"values": @{values}},
"transform": [
{"bin": {"maxbins": @{options.max_bins.value}}, "field": @{fields.value.name}, "as": "bin_start"}
],
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"mark": {"type": "bar", "color": "#255DD4", "tooltip": true},
"encoding": {
"x": {"field": "bin_start", "type": "quantitative", "bin": {"binned": true}, "axis": {"format": @{fields.value.format}, "formatType": "holisticsFormat"}},
"x2": {"field": "bin_start_end"},
"y": {"aggregate": "count", "title": "Count"}
},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelOverlap": "parity",
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisX": {"grid": false, "labelAngle": 0},
"axisY": {"domain": false, "grid": true},
"bar": {"cornerRadius": 2}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field grain {
type: 'dimension'
}
field metric {
type: 'dimension' // this type can be dimenson or measure depends on the type of field that you want to create the histogram on
}
}
template: @vgl
{
"data": {"values": @{values}},
"transform": [
{"bin": {"maxbins": 40}, "field": @{fields.metric.name}, "as": "binned_price"}
],
"mark": {
"type": "bar",
"tooltip": true
},
"encoding": {
"x": {"field": "binned_price", "type": "quantitative", "bin": {"binned": true, "step": 20}},
"x2": {"field": "binned_price_end"},
"y": {"aggregate": "count"}
}
}
;;
}
```
## Required fields
A Histogram expects exactly two fields. Each row of input is one record; the chart bins the numeric `value` and counts how many records land in each bin.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Grain of the data (one row per record). Sorted ascending (`apply_order: 1`). |
| `value` | Value | `dimension` | Numeric value to bin along the x-axis. Sorted ascending (`apply_order: 2`). |
**Data requirements:** Feed raw rows (one per record), not pre-aggregated counts, since the template bins `value` and counts records itself. `value` must be numeric.
**Sample data:**
| dimension | value |
|-----------|-------|
| order_1 | 42 |
| order_2 | 17 |
| order_3 | 88 |
| order_4 | 51 |
| order_5 | 23 |
| order_6 | 64 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------|---------|--------|
| `max_bins` | `40` | Maximum number of bins. Vega picks a clean bin width at or below this count; lower it for wider bins. |
## Known limitations
- **`value` must be numeric.** The bin transform runs on `value`, so a non-numeric field cannot be binned. Use a bar chart for categorical counts.
- **Needs raw rows, not aggregates.** The chart counts records per bin, so pre-summarized input distorts the distribution. Feed one row per record.
- **Bin width is approximate.** `max_bins` is an upper bound, not an exact count; Vega rounds to a readable bin width, so the rendered bar count can be lower.
---
## Custom Chart Library
export const githubIcon = (
);
A collection of ready-made Custom Chart templates, from distributions and hierarchies to KPIs and process monitoring. Pick one that matches what you want to show, then copy its definition into a Custom Chart and map it to your own fields.
Browse or contribute to the collection on GitHub, and share what you build with the community.
Contribute on GitHub
Share on Holistics Community
!["Overview"].includes(item.label)
)}
/>
---
## Packed Bubble Chart
A packed bubble chart shows category magnitudes as circles sized by value, where bubbles gravitate toward the center with a physics simulation and settle into place. Labels sit inside the larger bubbles, the layout adapts continuously to the container, and cluster tightness is tunable via the gravity options. Hovering a bubble highlights it and fades the others.
- **Good for:** comparing magnitudes across a single set of categories, showing relative size at a glance (revenue by product, headcount by team, traffic by source).
- **Not great for:** precise value comparison (use a bar chart), part-to-whole hierarchy (use a sunburst or treemap), or data that needs x/y coordinates (use a bubble plot).
## Syntax
Use the following AML definition to add the Packed Bubble Chart to your custom chart library.
```aml
CustomChartDef packed_bubble_force {
label: 'Packed Bubble (Force)'
description: 'To show category magnitudes as gravity-clustered bubbles, with labels inside the larger bubbles.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option gravity_x {
label: 'Horizontal gravity'
type: 'select'
options: [0.05, 0.1, 0.2, 0.3, 0.5]
default_value: 0.1
}
option gravity_y {
label: 'Vertical gravity'
type: 'select'
options: [0.05, 0.1, 0.2, 0.3, 0.5]
default_value: 0.2
}
option show_labels {
label: 'Show labels in bubbles'
type: 'toggle'
default_value: true
}
option color_scheme {
label: 'Color scheme'
type: 'select'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 10" }]
},
{
"name": "height",
"init": "containerSize()[1] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 10" }]
},
{"name": "cx", "update": "width / 2"},
{"name": "cy", "update": "height / 2"},
{"name": "gravityX", "update": "@{options.gravity_x.value}"},
{"name": "gravityY", "update": "@{options.gravity_y.value}"},
{"name": "maxSize", "update": "(width * height * 0.55) / max(1, length(data('table')))"},
{
"name": "hovered",
"value": null,
"on": [
{"events": "@nodes:mouseover", "update": "datum.category"},
{"events": "@nodes:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@nodes:click", "update": "{'@{fields.dimension.name}': [datum['category']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@nodes:mouseover", "update": "{'@{fields.dimension.name}': [datum['category']]}"},
{"events": "@nodes:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "table",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.dimension.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null && datum.amount > 0"},
{
"type": "aggregate",
"groupby": ["category"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
}
]
}
],
"scales": [
{
"name": "size",
"type": "linear",
"zero": true,
"domain": {"data": "table", "field": "amount"},
"range": [16, {"signal": "maxSize"}]
},
{
"name": "color",
"type": "ordinal",
"domain": {"data": "table", "field": "category", "sort": true},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"marks": [
{
"name": "nodes",
"type": "symbol",
"from": {"data": "table"},
"encode": {
"enter": {
"xfocus": {"signal": "cx"},
"yfocus": {"signal": "cy"}
},
"update": {
"size": {"scale": "size", "field": "amount"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null || hovered === datum.category ? 1 : 0.3"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1.5},
"tooltip": {
"signal": "{'Category': datum.category, 'Value': format(datum.amount, ',')}"
}
}
},
"transform": [
{
"type": "force",
"iterations": 100,
"static": false,
"forces": [
{
"force": "collide",
"iterations": 2,
"radius": {"expr": "sqrt(datum.size) / 2 + 1"}
},
{"force": "center", "x": {"signal": "cx"}, "y": {"signal": "cy"}},
{"force": "x", "x": "xfocus", "strength": {"signal": "gravityX"}},
{"force": "y", "y": "yfocus", "strength": {"signal": "gravityY"}}
]
}
]
},
{
"type": "text",
"interactive": false,
"from": {"data": "nodes"},
"encode": {
"update": {
"x": {"field": "x"},
"y": {"field": "y"},
"align": {"value": "center"},
"baseline": {"value": "middle"},
"text": {"field": "datum.category"},
"fontSize": {"signal": "clamp(sqrt(datum.size) / 5.5, 8, 14)"},
"fontWeight": {"value": 500},
"fill": {"value": "white"},
"fillOpacity": {
"signal": "hovered === null || hovered === datum.datum.category ? 1 : 0.3"
},
"limit": {"signal": "sqrt(datum.size) * 0.95"},
"opacity": {
"signal": "@{options.show_labels.value} && sqrt(datum.size) / 2 > 16 ? 1 : 0"
}
}
}
}
]
};;
}
```
## Required fields
A Packed Bubble Chart expects exactly two fields. Each row of input is one category whose value sets the bubble size.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category each bubble represents; shown as the bubble label. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Magnitude that sets the bubble area. Sorted descending (`apply_order: 2`). |
**Data requirements:** The template sums duplicate categories and filters out null and non-positive values, so you don't need to pre-aggregate. Use positive values, since the template drops zero and negative rows before rendering.
**Sample data:**
| dimension | value |
|-----------|-------|
| Search | 4200 |
| Direct | 3100 |
| Social | 2400 |
| Email | 1500 |
| Referral | 900 |
## Options
Set these options to adjust the layout without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|----------------|-------------|--------|
| `gravity_x` | `0.1` | Horizontal pull toward the center. Higher values pack bubbles tighter left-to-right. |
| `gravity_y` | `0.2` | Vertical pull toward the center. Higher values pack bubbles tighter top-to-bottom. |
| `show_labels` | `true` | Whether category labels appear inside bubbles. Labels still only show on bubbles large enough to fit them. |
| `color_scheme` | `tableau10` | Ordinal color palette applied across categories. |
## Known limitations
- **Positions are not quantitative.** Bubbles settle by physics simulation, so their x/y location carries no meaning. Use a bubble plot when you need two numeric axes.
- **Labels show only on larger bubbles.** Small bubbles omit their label even with `show_labels` on, since the text would not fit inside the circle.
- **Magnitudes are hard to compare precisely.** Area-based sizing reads relative scale well but not exact differences. Use a bar chart when precise comparison matters.
---
## Pareto Chart
A Pareto chart combines sorted bars with a cumulative percentage line to show which categories contribute most of the total. It answers 80/20 questions like "which customers drive most of our revenue?" at a glance.
- **Good for:** 80/20 analysis, ranking categories by contribution, finding the vital few that drive most of a total (revenue by customer, defects by cause, sales by product).
- **Not great for:** time series, part-to-whole composition across a hierarchy (use a sunburst or treemap), or data with too many categories to label along the x-axis.

## Syntax
Use the following AML definition to add the Pareto Chart to your custom chart library.
```aml
CustomChartDef pareto_chart {
label: 'Pareto Chart'
description: 'To combine sorted bars with a cumulative percentage line and reveal which categories contribute most of the total.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option bar_color {
type: 'color-picker'
label: 'Bar Color'
default_value: '#255dd4'
}
option line_color {
type: 'color-picker'
label: 'Cumulative Line Color'
default_value: '#e5484d'
}
option show_threshold {
type: 'toggle'
label: 'Show 80% threshold line'
default_value: true
}
option threshold {
type: 'number-input'
label: 'Threshold (0 to 1)'
default_value: 0.8
}
}
template: @vgl
{
"data": {"values": @{values}},
"transform": [
{
"sort": [{"field": @{fields.value.name}, "order": "descending"}],
"window": [{"op": "sum", "field": @{fields.value.name}, "as": "cumulative_sum"}],
"frame": [null, 0]
},
{
"joinaggregate": [{"op": "sum", "field": @{fields.value.name}, "as": "grand_total"}]
},
{
"calculate": "datum.cumulative_sum / datum.grand_total",
"as": "cumulative_percent"
},
{
"calculate": "datum['@{fields.value.name}'] / datum.grand_total",
"as": "item_share"
},
{
"joinaggregate": [{"op": "min", "field": @{fields.value.name}, "as": "min_value"}]
},
{
"calculate": "datum['@{fields.value.name}'] === datum.min_value ? (format(@{options.threshold.value}, '.0%') + ' of total') : ''",
"as": "threshold_label"
}
],
"encoding": {
"x": {
"field": @{fields.dimension.name},
"type": "nominal",
"sort": {"field": @{fields.value.name}, "order": "descending"},
"scale": {"paddingInner": 0.25}
}
},
"layer": [
{
"mark": {
"type": "bar",
"color": @{options.bar_color.value}
},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"title": null,
"axis": {
"format": @{fields.value.format},
"formatType": "holisticsFormat",
"grid": true,
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"gridOpacity": 1
}
},
"tooltip": [
{"field": @{fields.dimension.name}, "type": "nominal", "title": "Category"},
{"field": @{fields.value.name}, "type": "quantitative", "title": "Value", "format": @{fields.value.format}, "formatType": "holisticsFormat"},
{"field": "item_share", "type": "quantitative", "title": "Share of Total", "format": ".1%"},
{"field": "cumulative_percent", "type": "quantitative", "title": "Cumulative", "format": ".1%"}
]
}
},
{
"layer": [
{
"mark": {
"type": "line",
"point": {"filled": true, "size": 50, "fill": @{options.line_color.value}, "stroke": "white", "strokeWidth": 1},
"color": @{options.line_color.value}
},
"encoding": {
"y": {
"field": "cumulative_percent",
"type": "quantitative",
"axis": {
"format": ".0%",
"title": "Cumulative %",
"orient": "right",
"titleColor": @{options.line_color.value},
"grid": false
},
"scale": {"domain": [0, 1]}
},
"tooltip": [
{"field": @{fields.dimension.name}, "type": "nominal", "title": "Category"},
{"field": "cumulative_percent", "type": "quantitative", "title": "Cumulative", "format": ".1%"}
]
}
},
{
"mark": {
"type": "rule",
"strokeDash": [4, 4],
"color": "#9ba1a6",
"opacity": {"expr": "@{options.show_threshold.value} ? 1 : 0"}
},
"encoding": {
"x": null,
"y": {"datum": @{options.threshold.value}, "type": "quantitative"}
}
},
{
"mark": {
"type": "text",
"align": "right",
"baseline": "bottom",
"dx": 6,
"dy": -5,
"fontSize": 10,
"color": "#9ba1a6",
"opacity": {"expr": "@{options.show_threshold.value} ? 1 : 0"}
},
"encoding": {
"y": {"datum": @{options.threshold.value}, "type": "quantitative"},
"text": {"field": "threshold_label"}
}
}
]
}
],
"resolve": {"scale": {"y": "independent"}},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"domain": false,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleFontSize": 11,
"titleColor": "#858B9E"
},
"axisX": {
"title": null,
"grid": false,
"labelOverlap": "parity",
"labelAngle": -45,
"domainColor": "#bec1cb"
},
"axisY": {
"domain": false
},
"legend": {
"labelFontSize": 11,
"labelColor": "#858B9E",
"symbolStrokeWidth": 0
},
"bar": {
"cornerRadius": 2
}
}
}
;;
}
```
## Required fields
A Pareto Chart expects exactly two fields. Each row is one category with its value.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category shown as a bar along the x-axis. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Bar height; also drives the descending sort and the cumulative percentage line. Sorted descending (`apply_order: 2`). |
**Data requirements:** Pre-aggregate to one row per category, since the template does not combine duplicate categories before sorting and accumulating. The template sorts categories by `value` in descending order and computes each one's cumulative share of the grand total, so values should be non-negative for the cumulative line to climb correctly.
**Sample data:**
| dimension | value |
|---------------|-------|
| Pricing | 320 |
| Onboarding | 210 |
| Performance | 160 |
| Support | 95 |
| Documentation | 60 |
| Other | 35 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-------------|--------|
| `bar_color` | `#255dd4` | Fill color of the value bars. |
| `line_color` | `#e5484d` | Color of the cumulative percentage line, its points, and the right-axis title. |
| `show_threshold` | `true` | Shows or hides the dashed threshold reference line and its label. |
| `threshold` | `0.8` | Cumulative share (0 to 1) where the template draws the threshold line, for example `0.8` for the 80% mark. |
## Known limitations
- **The chart sorts categories by value, not by your dimension order.** The template forces a descending sort on `value`, so you cannot keep a custom category order on the x-axis.
- **Cumulative line assumes non-negative values.** The template computes the cumulative percentage against the grand total, so negative values distort the climb and can push the line outside the 0 to 1 range.
- **Too many categories crowd the axis.** Every category gets a labeled bar, so large category counts make the x-axis hard to read; group small categories into an "Other" bucket first.
---
## Radial Tree
A radial tree draws hierarchical data as a node-link diagram fanning out from a central root, with depth shown by distance from the center.
- **Good for:** category taxonomies (subcategory under category under department), org structures, or any nested grouping shown as a branching shape across 1-3 levels.
- **Not great for:** part-to-whole sizing (use a Treemap or Sunburst Chart, which size nodes by value), flat (non-hierarchical) data, or deep trees with more than three levels.

## Syntax
Use the following AML definition to add the Radial Tree to your custom chart library.
```aml
CustomChartDef radial_tree {
label: 'Radial Tree'
description: 'To draw a hierarchy of level columns as a radial node-link tree.'
fields {
field level_1 {
label: 'Level 1'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field level_2 {
label: 'Level 2'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field level_3 {
label: 'Level 3'
type: 'dimension'
sort {
apply_order: 3
direction: 'asc'
}
}
}
options {
option root_label {
label: 'Root label'
type: 'input'
default_value: 'All'
}
option layout {
label: 'Layout'
type: 'select'
options: ['tidy', 'cluster']
default_value: 'tidy'
}
option link_shape {
label: 'Link style'
type: 'select'
options: ['line', 'curve', 'diagonal', 'orthogonal']
default_value: 'curve'
}
option spread {
label: 'Angular spread (degrees)'
type: 'select'
options: [180, 270, 360]
default_value: 360
}
option show_labels {
label: 'Show labels'
type: 'toggle'
default_value: true
}
option color_scheme {
label: 'Color scheme (by depth)'
type: 'select'
options: ['blues', 'teals', 'greens', 'purples', 'viridis', 'magma']
default_value: 'blues'
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 10" }]
},
{
"name": "height",
"init": "containerSize()[1] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 10" }]
},
{"name": "originX", "update": "width / 2"},
{"name": "originY", "update": "height / 2"},
{"name": "radius", "update": "max(20, min(width, height) / 2 - 70)"},
{"name": "extent", "update": "@{options.spread.value}"},
{"name": "rotate", "value": 0},
{"name": "labels", "update": "@{options.show_labels.value}"},
{"name": "layout", "update": "'@{options.layout.value}'"},
{"name": "links", "update": "'@{options.link_shape.value}'"},
{
"name": "hovered",
"value": null,
"on": [
{"events": "@nodes:mouseover", "update": "datum.node_id"},
{"events": "@nodes:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@nodes:click", "update": "datum.depth === 1 ? {'@{fields.level_1.name}': [datum.label]} : datum.depth === 2 ? {'@{fields.level_2.name}': [datum.label]} : datum.depth === 3 ? {'@{fields.level_3.name}': [datum.label]} : null"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@nodes:mouseover", "update": "datum.depth === 1 ? {'@{fields.level_1.name}': [datum.label]} : datum.depth === 2 ? {'@{fields.level_2.name}': [datum.label]} : datum.depth === 3 ? {'@{fields.level_3.name}': [datum.label]} : null"},
{"events": "@nodes:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "tree",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.level_1.name}'] == null ? '' : '' + datum['@{fields.level_1.name}']", "as": "l1"},
{"type": "formula", "expr": "datum['@{fields.level_2.name}'] == null ? '' : '' + datum['@{fields.level_2.name}']", "as": "l2"},
{"type": "formula", "expr": "datum['@{fields.level_3.name}'] == null ? '' : '' + datum['@{fields.level_3.name}']", "as": "l3"},
{"type": "filter", "expr": "datum.l1 !== ''"},
{
"type": "formula",
"as": "node_arr",
"expr": "[{id: '__root__', parent: null, label: '@{options.root_label.value}'}, {id: '1§' + datum.l1, parent: '__root__', label: datum.l1}, (datum.l2 !== '' ? {id: '2§' + datum.l1 + '§' + datum.l2, parent: '1§' + datum.l1, label: datum.l2} : null), (datum.l2 !== '' && datum.l3 !== '' ? {id: '3§' + datum.l1 + '§' + datum.l2 + '§' + datum.l3, parent: '2§' + datum.l1 + '§' + datum.l2, label: datum.l3} : null)]"
},
{"type": "flatten", "fields": ["node_arr"], "as": ["node"]},
{"type": "filter", "expr": "datum.node != null"},
{"type": "formula", "expr": "datum.node.id", "as": "node_id"},
{"type": "formula", "expr": "datum.node.parent", "as": "parent_id"},
{"type": "formula", "expr": "datum.node.label", "as": "label"},
{"type": "aggregate", "groupby": ["node_id", "parent_id", "label"]},
{"type": "stratify", "key": "node_id", "parentKey": "parent_id"},
{
"type": "tree",
"method": {"signal": "layout"},
"size": [1, {"signal": "radius"}],
"as": ["alpha", "radius", "depth", "children"]
},
{"type": "formula", "expr": "(rotate + extent * datum.alpha + 270) % 360", "as": "angle"},
{"type": "formula", "expr": "PI * datum.angle / 180", "as": "radians"},
{"type": "formula", "expr": "inrange(datum.angle, [90, 270])", "as": "leftside"},
{"type": "formula", "expr": "originX + datum.radius * cos(datum.radians)", "as": "x"},
{"type": "formula", "expr": "originY + datum.radius * sin(datum.radians)", "as": "y"}
]
},
{
"name": "links",
"source": "tree",
"transform": [
{"type": "treelinks"},
{
"type": "linkpath",
"shape": {"signal": "links"},
"orient": "radial",
"sourceX": "source.radians",
"sourceY": "source.radius",
"targetX": "target.radians",
"targetY": "target.radius"
}
]
}
],
"scales": [
{
"name": "color",
"type": "linear",
"range": {"scheme": @{options.color_scheme.value}},
"domain": {"data": "tree", "field": "depth"},
"zero": true
}
],
"marks": [
{
"type": "path",
"from": {"data": "links"},
"encode": {
"update": {
"x": {"signal": "originX"},
"y": {"signal": "originY"},
"path": {"field": "path"},
"stroke": {"value": "#d0d5dd"},
"strokeWidth": {"value": 1}
}
}
},
{
"name": "nodes",
"type": "symbol",
"from": {"data": "tree"},
"encode": {
"enter": {
"size": {"value": 90},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1}
},
"update": {
"x": {"field": "x"},
"y": {"field": "y"},
"fill": {"scale": "color", "field": "depth"},
"size": {"signal": "datum.node_id === hovered ? 200 : 90"},
"tooltip": {
"signal": "{'Label': datum.label, 'Depth': datum.depth, 'Children': length(datum.children || [])}"
}
}
}
},
{
"type": "text",
"from": {"data": "tree"},
"interactive": false,
"encode": {
"enter": {
"text": {"field": "label"},
"fontSize": {"value": 10},
"baseline": {"value": "middle"},
"fill": {"value": "#374151"}
},
"update": {
"x": {"field": "x"},
"y": {"field": "y"},
"dx": {"signal": "(datum.leftside ? -1 : 1) * 7"},
"angle": {"signal": "datum.leftside ? datum.angle - 180 : datum.angle"},
"align": {"signal": "datum.leftside ? 'right' : 'left'"},
"opacity": {"signal": "labels ? 1 : 0"}
}
}
}
]
};;
}
```
## Required fields
A Radial Tree expects up to three fields, all dimensions. `level_1` is required; `level_2` and `level_3` are optional, so a branch can stop early when a deeper level is null. Each row contributes one path from the root down through its non-null levels.
| Field | Label | Type | Role |
|-----------|---------|-------------|------|
| `level_1` | Level 1 | `dimension` | First branch under the root (required). Sorted ascending (`apply_order: 1`). |
| `level_2` | Level 2 | `dimension` | Second branch under Level 1 (optional). Sorted ascending (`apply_order: 2`). |
| `level_3` | Level 3 | `dimension` | Outermost branch under Level 2 (optional). Sorted ascending (`apply_order: 3`). |
**Data requirements:** No measure is needed; the template builds the tree from the level columns alone, and node positions do not depend on any value. The template drops rows with a null or empty `level_1` and collapses duplicate node paths, so you don't need to pre-aggregate. Each child should roll up to a single parent across the columns, otherwise the same label appears under multiple branches.
**Sample data:**
| level_1 | level_2 | level_3 |
|-------------|----------|-----------|
| Electronics | Phones | iPhone |
| Electronics | Phones | Android |
| Electronics | Laptops | MacBook |
| Apparel | Shirts | T-shirt |
| Apparel | Shirts | Polo |
| Services | | |
## Options
Set these options to adjust the layout without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|----------------|---------|--------|
| `root_label` | `All` | Text shown on the central root node that all Level 1 branches connect to. |
| `layout` | `tidy` | Tree layout algorithm. `tidy` spaces nodes by structure; `cluster` aligns all leaf nodes at the same outer radius. |
| `link_shape` | `curve` | Style of the connecting links between nodes. |
| `spread` | `360` | Angular span the tree fans across, in degrees. `360` is a full circle; lower values open a wedge. |
| `show_labels` | `true` | Toggles the text label next to each node. |
| `color_scheme` | `blues` | Sequential color scheme used to shade nodes by their depth from the root. |
## Known limitations
- **Three levels maximum.** The template reads only `level_1` through `level_3`. Deeper hierarchies need extra level columns and matching node logic added to the template.
- **Nodes are not sized by value.** Every node is the same size and color encodes depth, not magnitude. Use a Treemap or Sunburst Chart when arc or area should reflect a measure.
- **Each child must roll up to one parent.** A label that appears under more than one parent renders as separate nodes, which misrepresents the hierarchy. Make sure the level columns form a clean tree.
---
## Ridgeline Chart
A ridgeline chart compares the distribution of a numeric value across categories, drawing one smooth density curve per category in a compact stack. Where a box plot shows summary statistics, a ridgeline shows the actual shape (skew, peaks, outlier tails) of each group.
- **Good for:** comparing the shape of one numeric value across categories, spotting skew or multiple peaks per group, seeing how distributions shift (delivery time by carrier, order value by segment).
- **Not great for:** a single ungrouped distribution (use a histogram), two-metric density (use a density contour plot), or exact summary statistics (use a box plot).

## Syntax
Use the following AML definition to add the Ridgeline Chart to your custom chart library.
```aml
CustomChartDef ridgeline_chart {
label: 'Ridgeline Chart'
description: 'To compare the distribution of a numeric value across categories, with one overlapping density curve per category.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'dimension' // numeric; use a row-level number, not an aggregated measure
data_type: 'number'
sort {
apply_order: 2
direction: 'asc'
}
}
}
options {
option overlap {
label: 'Ridge overlap'
type: 'select'
options: [1, 1.5, 2, 2.5, 3]
default_value: 2
}
option bandwidth {
label: 'KDE bandwidth (0 = automatic)'
type: 'number-input'
default_value: 0
}
option scale_by_count {
label: 'Scale ridge height by record count'
type: 'toggle'
default_value: false
}
option color_scheme {
label: 'Color scheme'
type: 'select'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 16",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 16" }]
},
{
"name": "height",
"init": "containerSize()[1] - 44",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 44" }]
},
{"name": "overlap", "update": "@{options.overlap.value}"},
{"name": "bandwidth", "update": "@{options.bandwidth.value}"},
{"name": "ext", "update": "length(data('value_extent')) ? data('value_extent')[0] : null"},
{"name": "iqr", "update": "ext ? ext.q3 - ext.q1 : 0"},
{"name": "vmin", "update": "ext ? (iqr > 0 ? max(ext.raw_min, ext.q1 - 1.5 * iqr) : ext.raw_min) : 0"},
{"name": "vmax", "update": "ext ? (iqr > 0 ? min(ext.raw_max, ext.q3 + 1.5 * iqr) : ext.raw_max) : 1"},
{"name": "domainMax", "update": "length(data('density_max')) ? data('density_max')[0].dmax : 1"},
{
"name": "hovered",
"value": null,
"on": [
{"events": "@ridge:mouseover", "update": "datum.category"},
{"events": "@ridge:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@ridge:click", "update": "{'@{fields.dimension.name}': [datum['category']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@ridge:mouseover", "update": "{'@{fields.dimension.name}': [datum['category']]}"},
{"events": "@ridge:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "source",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.dimension.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null"}
]
},
{
"name": "value_extent",
"source": "source",
"transform": [
{
"type": "aggregate",
"fields": ["amount", "amount", "amount", "amount"],
"ops": ["min", "max", "q1", "q3"],
"as": ["raw_min", "raw_max", "q1", "q3"]
}
]
},
{
"name": "density",
"source": "source",
"transform": [
{"type": "filter", "expr": "datum.amount >= vmin && datum.amount <= vmax"},
{
"type": "kde",
"groupby": ["category"],
"field": "amount",
"bandwidth": {"signal": "bandwidth"},
"extent": {"signal": "[vmin, vmax]"},
"steps": 200,
"counts": @{options.scale_by_count.value}
}
]
},
{
"name": "density_max",
"source": "density",
"transform": [
{"type": "aggregate", "fields": ["density"], "ops": ["max"], "as": ["dmax"]}
]
}
],
"scales": [
{
"name": "xscale",
"type": "linear",
"range": [0, {"signal": "width"}],
"zero": false,
"nice": true,
"domain": {"signal": "[vmin, vmax]"}
},
{
"name": "yscale",
"type": "band",
"range": [0, {"signal": "height"}],
"round": true,
"padding": 0,
"domain": {"data": "source", "field": "category", "sort": true}
},
{
"name": "color",
"type": "ordinal",
"domain": {"data": "source", "field": "category", "sort": true},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"axes": [
{"orient": "bottom", "scale": "xscale"},
{
"orient": "right",
"scale": "yscale",
"encode": {
"labels": {
"update": {
"dx": {"value": -4},
"dy": {"value": -2},
"y": {"scale": "yscale", "field": "value", "band": 1},
"align": {"value": "right"},
"baseline": {"value": "bottom"},
"fill": {"value": "#374151"},
"fontWeight": {
"signal": "hovered === datum.value ? 600 : 400"
}
}
}
}
}
],
"marks": [
{
"type": "group",
"from": {
"facet": {"data": "density", "name": "cat_density", "groupby": "category"}
},
"encode": {
"update": {
"y": {"scale": "yscale", "field": "category"},
"width": {"signal": "width"},
"height": {"signal": "bandwidth('yscale')"}
}
},
"sort": {"field": "y", "order": "ascending"},
"signals": [
{"name": "bandH", "update": "bandwidth('yscale')"}
],
"scales": [
{
"name": "yinner",
"type": "linear",
"range": [{"signal": "bandH"}, {"signal": "0 - overlap * bandH"}],
"domain": [0, {"signal": "domainMax"}]
}
],
"marks": [
{
"type": "rule",
"interactive": false,
"encode": {
"update": {
"x": {"value": 0},
"x2": {"signal": "width"},
"y": {"signal": "bandH", "offset": -0.5},
"stroke": {"value": "#E5E7EB"},
"strokeWidth": {"value": 0.5}
}
}
},
{
"type": "area",
"name": "ridge",
"from": {"data": "cat_density"},
"encode": {
"update": {
"x": {"scale": "xscale", "field": "value"},
"y": {"scale": "yinner", "field": "density"},
"y2": {"scale": "yinner", "value": 0},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null ? 0.7 : (hovered === datum.category ? 0.92 : 0.2)"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1},
"tooltip": {"signal": "datum.category"}
}
}
}
]
}
],
"config": {
"axis": {"domain": false, "ticks": false, "labelFontSize": 12},
"axisX": {"grid": false, "labelPadding": 8}
}
};;
}
```
## Required fields
A Ridgeline Chart expects exactly two fields. Each row of input is one observation; the chart groups rows by `dimension` and estimates a density curve per group.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category that gets one ridge (density curve). Sorted ascending (`apply_order: 1`). |
| `value` | Value | `dimension` | Numeric value whose distribution each ridge shows. Sorted ascending (`apply_order: 2`). |
**Data requirements:** Feed raw observations (one row per record), not pre-aggregated values, since the template estimates each category's density with KDE. `value` must be numeric; the template drops rows where it is null before rendering.
**Sample data:**
| dimension | value |
|-----------|-------|
| Carrier A | 2.1 |
| Carrier A | 3.4 |
| Carrier A | 2.8 |
| Carrier B | 4.5 |
| Carrier B | 5.2 |
| Carrier C | 1.9 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|-------------------|-------------|--------|
| `overlap` | `2` | How much each ridge overlaps the one above it. Higher values stack the curves more tightly. |
| `bandwidth` | `0` | KDE smoothing bandwidth. `0` lets Vega pick automatically; larger values produce smoother curves. |
| `scale_by_count` | `false` | When on, ridge height reflects each category's record count instead of scaling every ridge to its own shape. |
| `color_scheme` | `tableau10` | Ordinal color palette applied to categories. |
## Known limitations
- **`value` must be numeric.** The KDE runs on `value`, so a non-numeric field will not produce a density curve.
- **Needs raw rows, not aggregates.** The density estimate runs on individual observations, so pre-aggregated input gives a misleading shape. Feed one row per record.
- **The chart clips outliers to the Tukey fences.** It clamps the visible range to 1.5 IQR beyond the quartiles, so values in the extreme tails fall outside the drawn range.
---
## Sankey Chart
A Sankey chart visualizes how a subject moves between states or categories. Each node represents a state, and each link represents the volume flowing from one state to another. The wider the link, the larger the flow.
- **Good for:** user journeys, funnel analysis, budget or resource allocation.
- **Not great for:** cyclic data, time series, or charts with more than ~30 unique nodes.
## Syntax
Use the following AML definition to add the Sankey Chart to your custom chart library.
```aml
CustomChartDef sankey_chart {
label: 'Sankey Chart'
description: 'To trace how a subject flows between states, where each link width is proportional to its flow rate.'
fields {
field source {
label: 'Source'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field target {
label: 'Target'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
option node_align {
label: "Node Align"
type: "select"
options: ["justify", "left", "right", "center"]
default_value: "justify"
}
option node_width {
label: "Node Width"
type: "number-input"
default_value: 15
}
option node_padding {
label: "Node Padding"
type: "number-input"
default_value: 10
}
option margin_top {
label: "Margin Top"
type: "number-input"
default_value: 10
}
option margin_left {
label: "Margin Left"
type: "number-input"
default_value: 10
}
option margin_right {
label: "Margin right"
type: "number-input"
default_value: 10
}
option margin_bottom {
label: "Margin bottom"
type: "number-input"
default_value: 10
}
}
template: @vgl
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 1000,
"height": 600,
"autosize": "none",
"data": [
{
"name": "table",
"values": @{values}
},
{
"name": "nodesAndLinks",
"source": "table",
"transform": [
{
"type": "formula",
"expr": "width",
"as": "containerWidth"
},
{
"type": "formula",
"expr": "height",
"as": "containerHeight"
},
{
"type": "sankey",
"source": "datum['@{fields.source.name}']",
"target": "datum['@{fields.target.name}']",
"volume": "datum['@{fields.value.name}']",
"nodeAlign": @{options.node_align.value},
"nodeWidth": @{options.node_width.value},
"nodePadding": @{options.node_padding.value},
"marginTop": @{options.margin_top.value},
"marginRight": @{options.margin_right.value},
"marginBottom": @{options.margin_bottom.value},
"marginLeft": @{options.margin_left.value}
},
{
"type": "formula",
"expr": "datum.source.id",
"as": "sourceId"
},
{
"type": "formula",
"expr": "datum.target.id",
"as": "targetId"
}
]
},
{
"name": "links",
"source": "nodesAndLinks",
"transform": [
{
"type": "linkpath",
"orient": "horizontal",
"shape": "diagonal",
"sourceY": {
"expr": "datum.y0"
},
"sourceX": {
"expr": "datum.source.x1"
},
"targetY": {
"expr": "datum.y1"
},
"targetX": {
"expr": "datum.target.x0"
},
"as": "path"
},
{
"type": "formula",
"expr": "datum.width",
"as": "linkWidth"
}
]
},
{
"name": "extractedNode",
"source": "nodesAndLinks",
"transform": [
{
"type": "formula",
"expr": "datum.source.id",
"as": "sourceId"
},
{
"type": "formula",
"expr": "datum.source.x0",
"as": "sourceX0"
},
{
"type": "formula",
"expr": "datum.source.x1",
"as": "sourceX1"
},
{
"type": "formula",
"expr": "datum.source.y0",
"as": "sourceY0"
},
{
"type": "formula",
"expr": "datum.source.y1",
"as": "sourceY1"
},
{
"type": "formula",
"expr": "(datum.source.y0 + datum.source.y1)/2",
"as": "sourceYc"
},
{
"type": "formula",
"expr": "datum.target.id",
"as": "targetId"
},
{
"type": "formula",
"expr": "datum.target.x0",
"as": "targetX0"
},
{
"type": "formula",
"expr": "datum.target.x1",
"as": "targetX1"
},
{
"type": "formula",
"expr": "datum.target.y0",
"as": "targetY0"
},
{
"type": "formula",
"expr": "datum.target.y1",
"as": "targetY1"
},
{
"type": "formula",
"expr": "(datum.target.y0 + datum.target.y1)/2",
"as": "targetYc"
}
]
},
{
"name": "uniqueSource",
"source": "extractedNode",
"transform": [
{
"type": "aggregate",
"groupby": [
"sourceId",
"sourceX0",
"sourceX1",
"sourceY0",
"sourceY1",
"sourceYc"
]
}
]
},
{
"name": "uniqueTarget",
"source": "extractedNode",
"transform": [
{
"type": "aggregate",
"groupby": [
"targetId",
"targetX0",
"targetX1",
"targetY0",
"targetY1",
"targetYc"
]
}
]
}
],
"signals": [
{
"name": "width",
"init": "(containerSize()[0])",
"on": [
{
"update": "(containerSize()[0])",
"events": "window:resize"
}
]
},
{
"name": "height",
"init": "(containerSize()[1])",
"on": [
{
"update": "(containerSize()[1])",
"events": "window:resize"
}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@sourceMark:click", "update": "{'@{fields.source.name}': [datum.sourceId]}"},
{"events": "@targetMark:click", "update": "{'@{fields.target.name}': [datum.targetId]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@sourceMark:mouseover", "update": "{'@{fields.source.name}': [datum.sourceId]}"},
{"events": "@targetMark:mouseover", "update": "{'@{fields.target.name}': [datum.targetId]}"},
{"events": "@sourceMark:mouseout, @targetMark:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"scales": [
{
"name": "sourceColor",
"type": "ordinal",
"range": "category",
"domain": {
"data": "nodesAndLinks",
"field": "sourceId"
}
},
{
"name": "targetColor",
"type": "ordinal",
"range": "category",
"domain": {
"data": "nodesAndLinks",
"field": "targetId"
}
}
],
"marks": [
{
"type": "path",
"name": "edgeMark",
"from": {
"data": "links"
},
"clip": true,
"encode": {
"update": {
"path": {
"field": "path"
},
"strokeWidth": {
"field": "linkWidth"
},
"stroke": [
{
"scale": "sourceColor",
"field": "sourceId"
}
],
"strokeOpacity": {
"value": 0.6
},
"tooltip": {
"signal": "datum.sourceId + ' → ' + datum.targetId + ': ' + datum.value"
}
},
"hover": {
"strokeOpacity": {
"value": 1
}
}
}
},
{
"type": "rect",
"name": "targetMark",
"from": {
"data": "uniqueTarget"
},
"encode": {
"update": {
"x": {
"field": "targetX0"
},
"x2": {
"field": "targetX1"
},
"y": {
"field": "targetY0"
},
"y2": {
"field": "targetY1"
},
"fill": [
{
"scale": "targetColor",
"field": "targetId"
}
],
"stroke": {
"value": "#000"
},
"strokeWidth": {
"value": 0.5
}
},
"hover": {
"strokeWidth": {
"value": 3
}
}
}
},
{
"type": "rect",
"name": "sourceMark",
"from": {
"data": "uniqueSource"
},
"encode": {
"update": {
"x": {
"field": "sourceX0"
},
"x2": {
"field": "sourceX1"
},
"y": {
"field": "sourceY0"
},
"y2": {
"field": "sourceY1"
},
"fill": [
{
"scale": "sourceColor",
"field": "sourceId"
}
],
"stroke": {
"value": "#000"
},
"strokeWidth": {
"value": 0.5
}
},
"hover": {
"strokeWidth": {
"value": 3
}
}
}
},
{
"type": "text",
"name": "sourceTextMark",
"from": {
"data": "uniqueSource"
},
"interactive": false,
"encode": {
"update": {
"yc": {
"field": "sourceYc"
},
"x": {
"signal": "datum.sourceX1 > width / 2 ? datum.sourceX0 - 5 : datum.sourceX1 + 5"
},
"align": {
"signal": "datum.sourceX1 > width / 2 ? 'right' : 'left'"
},
"baseline": {
"value": "middle"
},
"fontWeight": {
"value": "normal"
},
"text": {
"field": "sourceId"
}
}
}
},
{
"type": "text",
"name": "targetTextMark",
"from": {
"data": "uniqueTarget"
},
"interactive": false,
"encode": {
"update": {
"yc": {
"field": "targetYc"
},
"x": {
"signal": "datum.targetX1 > width / 2 ? datum.targetX0 - 5 : datum.targetX1 + 5"
},
"align": {
"signal": "datum.targetX1 > width / 2 ? 'right' : 'left'"
},
"baseline": {
"value": "middle"
},
"fontWeight": {
"value": "normal"
},
"text": {
"field": "targetId"
}
}
}
}
]
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field source {
type: "dimension"
label: "Source Node"
data_type: "string"
}
field target {
type: "dimension"
label: "Target Node"
data_type: "string"
}
field volume {
type: "measure"
label: "Volume for link from Source-Target"
data_type: "number"
}
}
options {
option node_align {
label: "Node Align"
type: "select"
options: ["justify", "left", "right", "center"]
default_value: "justify"
}
option node_width {
label: "Node Width"
type: "number-input"
default_value: 15
}
option node_padding {
label: "Node Padding"
type: "number-input"
default_value: 10
}
option margin_top {
label: "Margin Top"
type: "number-input"
default_value: 10
}
option margin_left {
label: "Margin Left"
type: "number-input"
default_value: 10
}
option margin_right {
label: "Margin right"
type: "number-input"
default_value: 10
}
option margin_bottom {
label: "Margin bottom"
type: "number-input"
default_value: 10
}
}
template: @vgl
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 1000,
"height": 600,
"autosize": "none",
"data": [
{
"name": "table",
"values": @{values}
},
{
"name": "nodesAndLinks",
"source": "table",
"transform": [
{
"type": "formula",
"expr": "width",
"as": "containerWidth"
},
{
"type": "formula",
"expr": "height",
"as": "containerHeight"
},
{
"type": "sankey",
"source": "datum['@{fields.source.name}']",
"target": "datum['@{fields.target.name}']",
"volume": "datum['@{fields.volume.name}']",
"nodeAlign": @{options.node_align.value},
"nodeWidth": @{options.node_width.value},
"nodePadding": @{options.node_padding.value},
"marginTop": @{options.margin_top.value},
"marginRight": @{options.margin_right.value},
"marginBottom": @{options.margin_bottom.value},
"marginLeft": @{options.margin_left.value}
},
{
"type": "formula",
"expr": "datum.source.id",
"as": "sourceId"
},
{
"type": "formula",
"expr": "datum.target.id",
"as": "targetId"
}
]
},
{
"name": "links",
"source": "nodesAndLinks",
"transform": [
{
"type": "linkpath",
"orient": "horizontal",
"shape": "diagonal",
"sourceY": {
"expr": "datum.y0"
},
"sourceX": {
"expr": "datum.source.x1"
},
"targetY": {
"expr": "datum.y1"
},
"targetX": {
"expr": "datum.target.x0"
},
"as": "path"
},
{
"type": "formula",
"expr": "datum.width",
"as": "linkWidth"
}
]
},
{
"name": "extractedNode",
"source": "nodesAndLinks",
"transform": [
{
"type": "formula",
"expr": "datum.source.id",
"as": "sourceId"
},
{
"type": "formula",
"expr": "datum.source.x0",
"as": "sourceX0"
},
{
"type": "formula",
"expr": "datum.source.x1",
"as": "sourceX1"
},
{
"type": "formula",
"expr": "datum.source.y0",
"as": "sourceY0"
},
{
"type": "formula",
"expr": "datum.source.y1",
"as": "sourceY1"
},
{
"type": "formula",
"expr": "(datum.source.y0 + datum.source.y1)/2",
"as": "sourceYc"
},
{
"type": "formula",
"expr": "datum.target.id",
"as": "targetId"
},
{
"type": "formula",
"expr": "datum.target.x0",
"as": "targetX0"
},
{
"type": "formula",
"expr": "datum.target.x1",
"as": "targetX1"
},
{
"type": "formula",
"expr": "datum.target.y0",
"as": "targetY0"
},
{
"type": "formula",
"expr": "datum.target.y1",
"as": "targetY1"
},
{
"type": "formula",
"expr": "(datum.target.y0 + datum.target.y1)/2",
"as": "targetYc"
}
]
},
{
"name": "uniqueSource",
"source": "extractedNode",
"transform": [
{
"type": "aggregate",
"groupby": [
"sourceId",
"sourceX0",
"sourceX1",
"sourceY0",
"sourceY1",
"sourceYc"
]
}
]
},
{
"name": "uniqueTarget",
"source": "extractedNode",
"transform": [
{
"type": "aggregate",
"groupby": [
"targetId",
"targetX0",
"targetX1",
"targetY0",
"targetY1",
"targetYc"
]
}
]
}
],
"signals": [
{
"name": "width",
"init": "(containerSize()[0])",
"on": [
{
"update": "(containerSize()[0])",
"events": "window:resize"
}
]
},
{
"name": "height",
"init": "(containerSize()[1])",
"on": [
{
"update": "(containerSize()[1])",
"events": "window:resize"
}
]
}
],
"scales": [
{
"name": "sourceColor",
"type": "ordinal",
"range": "category",
"domain": {
"data": "nodesAndLinks",
"field": "sourceId"
}
},
{
"name": "targetColor",
"type": "ordinal",
"range": "category",
"domain": {
"data": "nodesAndLinks",
"field": "targetId"
}
}
],
"marks": [
{
"type": "path",
"name": "edgeMark",
"from": {
"data": "links"
},
"clip": true,
"encode": {
"update": {
"path": {
"field": "path"
},
"strokeWidth": {
"field": "linkWidth"
},
"stroke": [
{
"scale": "sourceColor",
"field": "sourceId"
}
],
"strokeOpacity": {
"value": 0.6
},
"tooltip": {
"signal": "datum.sourceId + ' → ' + datum.targetId + ': ' + datum.value"
}
},
"hover": {
"strokeOpacity": {
"value": 1
}
}
}
},
{
"type": "rect",
"name": "targetMark",
"from": {
"data": "uniqueTarget"
},
"encode": {
"update": {
"x": {
"field": "targetX0"
},
"x2": {
"field": "targetX1"
},
"y": {
"field": "targetY0"
},
"y2": {
"field": "targetY1"
},
"fill": [
{
"scale": "targetColor",
"field": "targetId"
}
],
"stroke": {
"value": "#000"
},
"strokeWidth": {
"value": 0.5
}
},
"hover": {
"strokeWidth": {
"value": 3
}
}
}
},
{
"type": "rect",
"name": "sourceMark",
"from": {
"data": "uniqueSource"
},
"encode": {
"update": {
"x": {
"field": "sourceX0"
},
"x2": {
"field": "sourceX1"
},
"y": {
"field": "sourceY0"
},
"y2": {
"field": "sourceY1"
},
"fill": [
{
"scale": "sourceColor",
"field": "sourceId"
}
],
"stroke": {
"value": "#000"
},
"strokeWidth": {
"value": 0.5
}
},
"hover": {
"strokeWidth": {
"value": 3
}
}
}
},
{
"type": "text",
"name": "sourceTextMark",
"from": {
"data": "uniqueSource"
},
"interactive": false,
"encode": {
"update": {
"yc": {
"field": "sourceYc"
},
"x": {
"signal": "datum.sourceX1 > width / 2 ? datum.sourceX0 - 5 : datum.sourceX1 + 5"
},
"align": {
"signal": "datum.sourceX1 > width / 2 ? 'right' : 'left'"
},
"baseline": {
"value": "middle"
},
"fontWeight": {
"value": "normal"
},
"text": {
"field": "sourceId"
}
}
}
},
{
"type": "text",
"name": "targetTextMark",
"from": {
"data": "uniqueTarget"
},
"interactive": false,
"encode": {
"update": {
"yc": {
"field": "targetYc"
},
"x": {
"signal": "datum.targetX1 > width / 2 ? datum.targetX0 - 5 : datum.targetX1 + 5"
},
"align": {
"signal": "datum.targetX1 > width / 2 ? 'right' : 'left'"
},
"baseline": {
"value": "middle"
},
"fontWeight": {
"value": "normal"
},
"text": {
"field": "targetId"
}
}
}
}
]
};;
}
```
## Required fields
A Sankey Chart expects exactly three fields. Each row of input is one directed link from a source node to a target node.
| Field | Label | Type | Role |
|----------|--------|-------------|------|
| `source` | Source | `dimension` | Originating node of the link. Sorted ascending (`apply_order: 1`). |
| `target` | Target | `dimension` | Destination node of the link. Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | Flow volume; sets the link width. Sorted descending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per source-target pair (for example, `SUM(value)` grouped by `source` and `target`); the template does not combine duplicate links. Use non-negative values, since zero-value rows render as invisible links. A node may appear in both `source` and `target`, where it renders as a pass-through node.
**Sample data:**
| source | target | value |
|--------------|--------------|-------|
| Homepage | Product Page | 4200 |
| Homepage | Blog | 1800 |
| Product Page | Cart | 2100 |
| Product Page | Exit | 2100 |
| Blog | Product Page | 900 |
| Blog | Exit | 900 |
| Cart | Checkout | 1500 |
| Cart | Exit | 600 |
## Options
Set these options to adjust the layout without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|-----------------|-----------|--------|
| `node_align` | `justify` | Horizontal placement of nodes. `justify` pushes source nodes left and sink nodes right. |
| `node_width` | `15` | Width of each node rectangle, in pixels. |
| `node_padding` | `10` | Vertical gap between nodes in the same column. Increase if labels overlap. |
| `margin_top` | `10` | Top margin inside the chart container, in pixels. |
| `margin_right` | `10` | Right margin inside the chart container, in pixels. |
| `margin_bottom` | `10` | Bottom margin inside the chart container, in pixels. |
| `margin_left` | `10` | Left margin inside the chart container, in pixels. |
## Known limitations
- **No cyclic flows.** The source-to-target graph must be acyclic. Circular paths cause a Vega runtime error, so reshape or remove cycles before charting.
- **Readability drops past ~30 nodes.** Beyond roughly 30 unique nodes the links get too thin to distinguish. Aggregate small nodes into an "Other" group first.
---
## Slope Chart
A slope chart compares each category's value at two points in time, with one line per category whose slope tells the story. The chart colors lines by direction (up, down, flat) and labels both ends, so there is no legend to decode.
- **Good for:** before-and-after comparisons like this quarter vs last quarter revenue by region, NPS by segment around a launch, or cost per team across two budget cycles.
- **Not great for:** trends across many periods (use the [Bump Chart](/docs/charts/custom-charts/library/bump-chart) for ranks or a line chart for values), or more than ~10 categories where labels and lines crowd together (use the [Faceted Sparkline](/docs/charts/custom-charts/library/faceted-sparkline)).

## Syntax
Use the following AML definition to add the Slope Chart to your custom chart library.
```aml
CustomChartDef slope_chart {
label: 'Slope Chart'
description: 'To compare each category at two points in time, with lines colored by direction of change and labeled at both ends.'
fields {
field period {
label: 'Period'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'asc'
}
}
}
options {
option increase_color {
label: 'Increase Color'
type: 'color-picker'
default_value: '#2cb67f'
}
option decrease_color {
label: 'Decrease Color'
type: 'color-picker'
default_value: '#e5484d'
}
option show_change {
label: 'Show % change on the right label'
type: 'toggle'
default_value: true
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0]",
"on": [{ "events": "window:resize", "update": "containerSize()[0]" }]
},
{
"name": "height",
"init": "containerSize()[1]",
"on": [{ "events": "window:resize", "update": "containerSize()[1]" }]
},
{"name": "gutter", "update": "clamp(width * 0.22, 90, 240)"},
{"name": "firstPeriod", "update": "length(data('periods')) ? data('periods')[0].period : null"},
{"name": "lastPeriod", "update": "length(data('periods')) ? data('periods')[length(data('periods')) - 1].period : null"},
{
"name": "hoveredCat",
"value": null,
"on": [
{
"events": "@slopeLine:mouseover, @startDot:mouseover, @endDot:mouseover, @leftLabel:mouseover, @rightLabel:mouseover",
"update": "datum.category"
},
{
"events": "@slopeLine:mouseout, @startDot:mouseout, @endDot:mouseout, @leftLabel:mouseout, @rightLabel:mouseout",
"update": "null"
}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@slopeLine:click, @startDot:click, @endDot:click, @leftLabel:click, @rightLabel:click", "update": "{'@{fields.dimension.name}': [datum['category']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@slopeLine:mouseover, @startDot:mouseover, @endDot:mouseover, @leftLabel:mouseover, @rightLabel:mouseover", "update": "{'@{fields.dimension.name}': [datum['category']]}"},
{"events": "@slopeLine:mouseout, @startDot:mouseout, @endDot:mouseout, @leftLabel:mouseout, @rightLabel:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "source",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.period.name}']", "as": "period"},
{"type": "formula", "expr": "datum['@{fields.dimension.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null"}
]
},
{
"name": "periods",
"source": "source",
"transform": [
{"type": "aggregate", "groupby": ["period"]},
{"type": "collect", "sort": {"field": "period"}}
]
},
{
"name": "starts",
"source": "source",
"transform": [
{"type": "filter", "expr": "datum.period === firstPeriod"},
{
"type": "aggregate",
"groupby": ["category"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
}
]
},
{
"name": "ends",
"source": "source",
"transform": [
{"type": "filter", "expr": "datum.period === lastPeriod"},
{
"type": "aggregate",
"groupby": ["category"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["end_amount"]
}
]
},
{
"name": "slopes",
"source": "starts",
"transform": [
{
"type": "lookup",
"from": "ends",
"key": "category",
"fields": ["category"],
"values": ["end_amount"],
"as": ["end_amount"]
},
{"type": "filter", "expr": "datum.end_amount != null"},
{"type": "formula", "expr": "datum.end_amount - datum.amount", "as": "delta"},
{
"type": "formula",
"expr": "datum.delta > 0 ? 'up' : (datum.delta < 0 ? 'down' : 'flat')",
"as": "direction"
},
{"type": "collect", "sort": {"field": "category"}}
]
},
{
"name": "extents",
"source": "slopes",
"transform": [
{"type": "fold", "fields": ["amount", "end_amount"], "as": ["which", "v"]}
]
},
{
"name": "left_labels",
"source": "slopes",
"transform": [
{"type": "formula", "expr": "scale('y', datum.amount)", "as": "targetY"},
{"type": "formula", "expr": "0", "as": "fx"},
{
"type": "force",
"static": true,
"forces": [
{"force": "x", "x": "fx", "strength": 1},
{"force": "y", "y": "targetY", "strength": 0.6},
{"force": "collide", "radius": 8}
]
}
]
},
{
"name": "right_labels",
"source": "slopes",
"transform": [
{"type": "formula", "expr": "scale('y', datum.end_amount)", "as": "targetY"},
{"type": "formula", "expr": "0", "as": "fx"},
{
"type": "force",
"static": true,
"forces": [
{"force": "x", "x": "fx", "strength": 1},
{"force": "y", "y": "targetY", "strength": 0.6},
{"force": "collide", "radius": 8}
]
}
]
}
],
"scales": [
{
"name": "x",
"type": "point",
"domain": {"signal": "[firstPeriod, lastPeriod]"},
"range": [{"signal": "gutter"}, {"signal": "width - gutter"}]
},
{
"name": "y",
"type": "linear",
"nice": true,
"zero": false,
"domain": {"data": "extents", "field": "v"},
"range": [{"signal": "height - 30"}, 14]
},
{
"name": "color",
"type": "ordinal",
"domain": ["up", "down", "flat"],
"range": [@{options.increase_color.value}, @{options.decrease_color.value}, "#9ba1a6"]
}
],
"axes": [
{"orient": "bottom", "scale": "x", "offset": -22}
],
"marks": [
{
"type": "rule",
"name": "slopeLine",
"from": {"data": "slopes"},
"encode": {
"update": {
"x": {"scale": "x", "signal": "firstPeriod"},
"x2": {"scale": "x", "signal": "lastPeriod"},
"y": {"scale": "y", "field": "amount"},
"y2": {"scale": "y", "field": "end_amount"},
"stroke": {"scale": "color", "field": "direction"},
"strokeWidth": {"signal": "hoveredCat === datum.category ? 3.5 : 2"},
"opacity": {"signal": "hoveredCat === null || hoveredCat === datum.category ? 1 : 0.15"},
"tooltip": {
"signal": "{'Category': datum.category, 'From': format(datum.amount, ','), 'To': format(datum.end_amount, ','), 'Change': format(datum.delta, '+,') + (datum.amount !== 0 ? ' (' + format(datum.delta / datum.amount, '+.1%') + ')' : '')}"
}
}
}
},
{
"type": "symbol",
"name": "startDot",
"from": {"data": "slopes"},
"encode": {
"update": {
"x": {"scale": "x", "signal": "firstPeriod"},
"y": {"scale": "y", "field": "amount"},
"fill": {"scale": "color", "field": "direction"},
"size": {"value": 70},
"opacity": {"signal": "hoveredCat === null || hoveredCat === datum.category ? 1 : 0.15"}
}
}
},
{
"type": "symbol",
"name": "endDot",
"from": {"data": "slopes"},
"encode": {
"update": {
"x": {"scale": "x", "signal": "lastPeriod"},
"y": {"scale": "y", "field": "end_amount"},
"fill": {"scale": "color", "field": "direction"},
"size": {"value": 70},
"opacity": {"signal": "hoveredCat === null || hoveredCat === datum.category ? 1 : 0.15"}
}
}
},
{
"type": "text",
"name": "leftLabel",
"from": {"data": "left_labels"},
"encode": {
"update": {
"x": {"scale": "x", "signal": "firstPeriod", "offset": -10},
"y": {"field": "y"},
"align": {"value": "right"},
"baseline": {"value": "middle"},
"text": {"signal": "datum.category + ' ' + format(datum.amount, ',')"},
"limit": {"signal": "gutter - 14"},
"fontSize": {"value": 11},
"fontWeight": {"signal": "hoveredCat === datum.category ? 700 : 400"},
"fill": {"value": "#374151"},
"opacity": {"signal": "hoveredCat === null || hoveredCat === datum.category ? 1 : 0.25"}
}
}
},
{
"type": "text",
"name": "rightLabel",
"from": {"data": "right_labels"},
"encode": {
"update": {
"x": {"scale": "x", "signal": "lastPeriod", "offset": 10},
"y": {"field": "y"},
"align": {"value": "left"},
"baseline": {"value": "middle"},
"text": {
"signal": "format(datum.end_amount, ',') + (@{options.show_change.value} && datum.amount !== 0 ? ' (' + format(datum.delta / datum.amount, '+.0%') + ')' : '') + ' ' + datum.category"
},
"limit": {"signal": "gutter - 14"},
"fontSize": {"value": 11},
"fontWeight": {"signal": "hoveredCat === datum.category ? 700 : 400"},
"fill": {"value": "#374151"},
"opacity": {"signal": "hoveredCat === null || hoveredCat === datum.category ? 1 : 0.25"}
}
}
}
],
"config": {
"axis": {"domain": false, "ticks": false, "labelFontSize": 12, "labelFontWeight": 600, "labelPadding": 8}
}
};;
}
```
## Required fields
A Slope Chart expects exactly three fields. Each row is one category's value in one period, and the template draws one line per category between the first and last period.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `period` | Period | `dimension` | The two endpoints on the x-axis; the template uses only the first and last period values. Sorted ascending (`apply_order: 1`). |
| `dimension` | Dimension | `dimension` | The category; one slope line per category, labeled at both ends. Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | The amount plotted at each endpoint, setting line height and slope direction. Sorted ascending (`apply_order: 3`). |
**Data requirements:** The template sums duplicate category rows within a period and drops null values, so you don't need to pre-aggregate. Each category needs a value at both the first and last period; the chart drops categories present in only one of the two. If the data has more than two periods, it compares only the first and last.
**Sample data:**
| period | dimension | value |
|---------|-----------|-------|
| 2024-Q1 | North | 4200 |
| 2024-Q1 | South | 3100 |
| 2024-Q1 | East | 2600 |
| 2024-Q1 | West | 1800 |
| 2024-Q4 | North | 3900 |
| 2024-Q4 | South | 3600 |
| 2024-Q4 | East | 2400 |
| 2024-Q4 | West | 2700 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|------------------|-------------|--------|
| `increase_color` | `#2cb67f` | Color for lines that go up between the two periods. |
| `decrease_color` | `#e5484d` | Color for lines that go down between the two periods. |
| `show_change` | `true` | When on, appends the percent change to each category's right-hand (end) label. |
## Known limitations
- **Compares exactly two periods.** With more than two periods, the chart plots only the first and last and ignores the middle. Use the [Bump Chart](/docs/charts/custom-charts/library/bump-chart) or a line chart to show every period.
- **Categories need both endpoints.** The chart drops any category missing at either the first or last period, since it has no slope to draw.
- **Readability drops past ~10 categories.** Beyond roughly 10 lines the direct labels overlap and slopes get hard to separate. Group smaller categories together first.
---
## Sunburst Chart
A sunburst chart visualizes hierarchical composition as concentric rings. The inner ring breaks the total into categories, and outer rings split each category into its children. The wider the arc, the larger the value.
- **Good for:** part-to-whole analysis across 2-3 levels of hierarchy (revenue by product line and product, tickets by team and type, budget by department and cost center).
- **Not great for:** flat (non-hierarchical) data, time series, or more than ~10 categories on the inner ring (slices get too thin to read).

## Syntax
Two variants are available:
- **Two-level**: category + subcategory
- **Three-level**: category + subcategory + item
### Two-level hierarchy
```aml
CustomChartDef sunburst_chart {
label: 'Sunburst Chart'
description: 'To show a two-level hierarchy as concentric rings, with categories on the inner ring and subcategories on the outer ring.'
fields {
field level_1 {
label: 'Level 1'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field level_2 {
label: 'Level 2'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
option color_scheme {
type: 'select'
label: 'Color scheme'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
option donut_hole {
type: 'select'
label: 'Center hole size'
options: [0, 0.2, 0.35, 0.5]
default_value: 0.35
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 5" }]
},
{
"name": "height",
"init": "containerSize()[1] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 5" }]
},
{"name": "radius", "update": "min(width, height) / 2"},
{"name": "holeR", "update": "radius * @{options.donut_hole.value}"},
{"name": "ringSplit", "update": "holeR + (radius - holeR) * 0.55"},
{"name": "grandTotal", "update": "length(data('cats')) ? data('cats')[0].total : 0"},
{
"name": "hovered",
"value": null,
"on": [
{
"events": "@catArc:mouseover",
"update": "{kind: 'category', category: datum.category, label: datum.category, amount: datum.amount, share: datum.amount / datum.total}"
},
{
"events": "@leafArc:mouseover",
"update": "{kind: 'subcategory', category: datum.category, label: datum.subcategory, amount: datum.amount, share: datum.amount / datum.total}"
},
{"events": "@catArc:mouseout, @leafArc:mouseout", "update": "null"}
]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@catArc:click", "update": "{'@{fields.level_1.name}': [datum.category]}"},
{"events": "@leafArc:click", "update": "{'@{fields.level_2.name}': [datum.subcategory]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@catArc:mouseover", "update": "{'@{fields.level_1.name}': [datum.category]}"},
{"events": "@leafArc:mouseover", "update": "{'@{fields.level_2.name}': [datum.subcategory]}"},
{"events": "@catArc:mouseout, @leafArc:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "leaves",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.level_1.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.level_2.name}']", "as": "subcategory"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null && datum.amount > 0"},
{
"type": "aggregate",
"groupby": ["category", "subcategory"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
},
{"type": "collect", "sort": {"field": ["category", "subcategory"]}},
{
"type": "stack",
"field": "amount",
"as": ["s0", "s1"]
},
{
"type": "joinaggregate",
"fields": ["amount"],
"ops": ["sum"],
"as": ["total"]
},
{
"type": "joinaggregate",
"fields": ["amount"],
"ops": ["sum"],
"as": ["cat_total"],
"groupby": ["category"]
},
{"type": "formula", "expr": "datum.s0 / datum.total * 2 * PI", "as": "a0"},
{"type": "formula", "expr": "datum.s1 / datum.total * 2 * PI", "as": "a1"}
]
},
{
"name": "cats",
"source": "leaves",
"transform": [
{
"type": "aggregate",
"groupby": ["category"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
},
{"type": "collect", "sort": {"field": "category"}},
{
"type": "stack",
"field": "amount",
"as": ["s0", "s1"]
},
{
"type": "joinaggregate",
"fields": ["amount"],
"ops": ["sum"],
"as": ["total"]
},
{"type": "formula", "expr": "datum.s0 / datum.total * 2 * PI", "as": "a0"},
{"type": "formula", "expr": "datum.s1 / datum.total * 2 * PI", "as": "a1"}
]
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"domain": {"data": "cats", "field": "category"},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"marks": [
{
"type": "arc",
"name": "catArc",
"from": {"data": "cats"},
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"},
"startAngle": {"field": "a0"},
"endAngle": {"field": "a1"},
"innerRadius": {"signal": "holeR"},
"outerRadius": {"signal": "ringSplit"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null || hovered.category === datum.category ? 1 : 0.3"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1.5},
"tooltip": {
"signal": "{'Category': datum.category, 'Value': format(datum.amount, ','), 'Share of Total': format(datum.amount / datum.total, '.1%')}"
}
}
}
},
{
"type": "arc",
"name": "leafArc",
"from": {"data": "leaves"},
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"},
"startAngle": {"field": "a0"},
"endAngle": {"field": "a1"},
"innerRadius": {"signal": "ringSplit + 1"},
"outerRadius": {"signal": "radius"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null ? 0.7 : (hovered.category !== datum.category ? 0.2 : (hovered.kind === 'subcategory' && hovered.label === datum.subcategory ? 1 : 0.75))"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1},
"tooltip": {
"signal": "{'Subcategory': datum.subcategory, 'Category': datum.category, 'Value': format(datum.amount, ','), 'Share of Total': format(datum.amount / datum.total, '.1%'), 'Share of Category': format(datum.amount / datum.cat_total, '.1%')}"
}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2 - 14"},
"align": {"value": "center"},
"text": {"signal": "hovered === null ? 'Total' : hovered.label"},
"limit": {"signal": "holeR * 1.7"},
"fontSize": {"value": 13},
"fontWeight": {"value": 600},
"fill": {"value": "#374151"},
"opacity": {"signal": "holeR > 30 ? 1 : 0"}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2 + 8"},
"align": {"value": "center"},
"text": {"signal": "format(hovered === null ? grandTotal : hovered.amount, ',')"},
"fontSize": {"value": 16},
"fontWeight": {"value": 700},
"fill": {"value": "#111827"},
"opacity": {"signal": "holeR > 30 ? 1 : 0"}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2 + 28"},
"align": {"value": "center"},
"text": {"signal": "hovered === null ? '' : format(hovered.share, '.1%') + ' of total'"},
"fontSize": {"value": 11},
"fill": {"value": "#6b7280"},
"opacity": {"signal": "holeR > 30 ? 1 : 0"}
}
}
}
]
};;
}
```
### Three-level hierarchy
The same chart with one more ring (for example, product line, product, then variant). Works best when the outer ring has a manageable number of slices; beyond a few dozen items the tooltips remain usable but the slices get thin.

```aml
CustomChartDef sunburst_chart_three_level {
label: 'Sunburst Chart (3 levels)'
description: 'To show a three-level hierarchy as nested rings of category, subcategory, and item composition.'
fields {
field level_1 {
label: 'Level 1'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field level_2 {
label: 'Level 2'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field level_3 {
label: 'Level 3'
type: 'dimension'
sort {
apply_order: 3
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 4
direction: 'desc'
}
}
}
options {
option color_scheme {
type: 'select'
label: 'Color scheme'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
option donut_hole {
type: 'select'
label: 'Center hole size'
options: [0, 0.2, 0.35, 0.5]
default_value: 0.35
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 5" }]
},
{
"name": "height",
"init": "containerSize()[1] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 5" }]
},
{"name": "radius", "update": "min(width, height) / 2"},
{"name": "holeR", "update": "radius * @{options.donut_hole.value}"},
{"name": "ring1End", "update": "holeR + (radius - holeR) * 0.38"},
{"name": "ring2End", "update": "holeR + (radius - holeR) * 0.7"},
{"name": "grandTotal", "update": "length(data('cats')) ? data('cats')[0].total : 0"},
{
"name": "hovered",
"value": null,
"on": [
{
"events": "@catArc:mouseover",
"update": "{kind: 'category', category: datum.category, subcategory: null, label: datum.category, amount: datum.amount, share: datum.amount / datum.total}"
},
{
"events": "@subArc:mouseover",
"update": "{kind: 'subcategory', category: datum.category, subcategory: datum.subcategory, label: datum.subcategory, amount: datum.amount, share: datum.amount / datum.total}"
},
{
"events": "@leafArc:mouseover",
"update": "{kind: 'item', category: datum.category, subcategory: datum.subcategory, label: datum.item, amount: datum.amount, share: datum.amount / datum.total}"
},
{"events": "@catArc:mouseout, @subArc:mouseout, @leafArc:mouseout", "update": "null"}
]
}
],
"data": [
{
"name": "leaves",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.level_1.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.level_2.name}']", "as": "subcategory"},
{"type": "formula", "expr": "datum['@{fields.level_3.name}']", "as": "item"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null && datum.amount > 0"},
{
"type": "aggregate",
"groupby": ["category", "subcategory", "item"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
},
{"type": "collect", "sort": {"field": ["category", "subcategory", "item"]}},
{"type": "stack", "field": "amount", "as": ["s0", "s1"]},
{"type": "joinaggregate", "fields": ["amount"], "ops": ["sum"], "as": ["total"]},
{
"type": "joinaggregate",
"fields": ["amount"],
"ops": ["sum"],
"as": ["sub_total"],
"groupby": ["category", "subcategory"]
},
{"type": "formula", "expr": "datum.s0 / datum.total * 2 * PI", "as": "a0"},
{"type": "formula", "expr": "datum.s1 / datum.total * 2 * PI", "as": "a1"}
]
},
{
"name": "subs",
"source": "leaves",
"transform": [
{
"type": "aggregate",
"groupby": ["category", "subcategory"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
},
{"type": "collect", "sort": {"field": ["category", "subcategory"]}},
{"type": "stack", "field": "amount", "as": ["s0", "s1"]},
{"type": "joinaggregate", "fields": ["amount"], "ops": ["sum"], "as": ["total"]},
{
"type": "joinaggregate",
"fields": ["amount"],
"ops": ["sum"],
"as": ["cat_total"],
"groupby": ["category"]
},
{"type": "formula", "expr": "datum.s0 / datum.total * 2 * PI", "as": "a0"},
{"type": "formula", "expr": "datum.s1 / datum.total * 2 * PI", "as": "a1"}
]
},
{
"name": "cats",
"source": "leaves",
"transform": [
{
"type": "aggregate",
"groupby": ["category"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
},
{"type": "collect", "sort": {"field": "category"}},
{"type": "stack", "field": "amount", "as": ["s0", "s1"]},
{"type": "joinaggregate", "fields": ["amount"], "ops": ["sum"], "as": ["total"]},
{"type": "formula", "expr": "datum.s0 / datum.total * 2 * PI", "as": "a0"},
{"type": "formula", "expr": "datum.s1 / datum.total * 2 * PI", "as": "a1"}
]
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"domain": {"data": "cats", "field": "category"},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"marks": [
{
"type": "arc",
"name": "catArc",
"from": {"data": "cats"},
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"},
"startAngle": {"field": "a0"},
"endAngle": {"field": "a1"},
"innerRadius": {"signal": "holeR"},
"outerRadius": {"signal": "ring1End"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null || hovered.category === datum.category ? 1 : 0.3"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1.5},
"tooltip": {
"signal": "{'Category': datum.category, 'Value': format(datum.amount, ','), 'Share of Total': format(datum.amount / datum.total, '.1%')}"
}
}
}
},
{
"type": "arc",
"name": "subArc",
"from": {"data": "subs"},
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"},
"startAngle": {"field": "a0"},
"endAngle": {"field": "a1"},
"innerRadius": {"signal": "ring1End + 1"},
"outerRadius": {"signal": "ring2End"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null ? 0.85 : (hovered.category !== datum.category ? 0.2 : (hovered.subcategory === null ? 0.9 : (hovered.subcategory === datum.subcategory ? 1 : 0.5)))"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1.2},
"tooltip": {
"signal": "{'Subcategory': datum.subcategory, 'Category': datum.category, 'Value': format(datum.amount, ','), 'Share of Total': format(datum.amount / datum.total, '.1%'), 'Share of Category': format(datum.amount / datum.cat_total, '.1%')}"
}
}
}
},
{
"type": "arc",
"name": "leafArc",
"from": {"data": "leaves"},
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2"},
"startAngle": {"field": "a0"},
"endAngle": {"field": "a1"},
"innerRadius": {"signal": "ring2End + 1"},
"outerRadius": {"signal": "radius"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null ? 0.65 : (hovered.category !== datum.category ? 0.15 : (hovered.kind === 'item' ? (hovered.label === datum.item ? 1 : 0.45) : (hovered.kind === 'subcategory' ? (hovered.subcategory === datum.subcategory ? 0.95 : 0.4) : 0.8)))"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1},
"tooltip": {
"signal": "{'Item': datum.item, 'Subcategory': datum.subcategory, 'Category': datum.category, 'Value': format(datum.amount, ','), 'Share of Total': format(datum.amount / datum.total, '.1%'), 'Share of Subcategory': format(datum.amount / datum.sub_total, '.1%')}"
}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2 - 14"},
"align": {"value": "center"},
"text": {"signal": "hovered === null ? 'Total' : hovered.label"},
"limit": {"signal": "holeR * 1.7"},
"fontSize": {"value": 13},
"fontWeight": {"value": 600},
"fill": {"value": "#374151"},
"opacity": {"signal": "holeR > 30 ? 1 : 0"}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2 + 8"},
"align": {"value": "center"},
"text": {"signal": "format(hovered === null ? grandTotal : hovered.amount, ',')"},
"fontSize": {"value": 16},
"fontWeight": {"value": 700},
"fill": {"value": "#111827"},
"opacity": {"signal": "holeR > 30 ? 1 : 0"}
}
}
},
{
"type": "text",
"interactive": false,
"encode": {
"update": {
"x": {"signal": "width / 2"},
"y": {"signal": "height / 2 + 28"},
"align": {"value": "center"},
"text": {"signal": "hovered === null ? '' : format(hovered.share, '.1%') + ' of total'"},
"fontSize": {"value": 11},
"fill": {"value": "#6b7280"},
"opacity": {"signal": "holeR > 30 ? 1 : 0"}
}
}
}
]
};;
}
```
## Required fields
The inner ring is `level_1`; each additional level radiates outward. A two-level chart takes three fields; the three-level variant takes four. Sort order on the dimension fields sets the angular order of slices within each ring.
**Two-level variant:**
| Field | Label | Type | Role |
|-----------|---------|-------------|------|
| `level_1` | Level 1 | `dimension` | Inner ring (categories). Sorted ascending (`apply_order: 1`). |
| `level_2` | Level 2 | `dimension` | Outer ring (subcategories). Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | Slice size. Sorted descending (`apply_order: 3`). |
**Three-level variant:**
| Field | Label | Type | Role |
|-----------|---------|-------------|------|
| `level_1` | Level 1 | `dimension` | Inner ring (categories). Sorted ascending (`apply_order: 1`). |
| `level_2` | Level 2 | `dimension` | Middle ring (subcategories). Sorted ascending (`apply_order: 2`). |
| `level_3` | Level 3 | `dimension` | Outer ring (items). Sorted ascending (`apply_order: 3`). |
| `value` | Value | `measure` | Slice size. Sorted descending (`apply_order: 4`). |
**Data requirements:** The template aggregates duplicate combinations and filters out null and non-positive values, so you don't need to pre-aggregate. Use positive values, since the template drops zero and negative rows before rendering.
**Sample data:**
The three-level variant uses all four columns. The two-level variant uses the same shape minus `level_3`.
| level_1 | level_2 | level_3 | value |
|-------------|---------|---------|-------|
| Electronics | Phones | iPhone | 24000 |
| Electronics | Phones | Android | 18000 |
| Electronics | Laptops | MacBook | 20000 |
| Electronics | Laptops | Windows | 11000 |
| Apparel | Shirts | T-shirt | 10000 |
| Apparel | Shirts | Polo | 8000 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values. Both variants share the same options.
| Option | Default | Effect |
|----------------|-------------|--------|
| `color_scheme` | `tableau10` | Ordinal color palette applied to Level 1 categories. All child rings inherit their parent category's color. |
| `donut_hole` | `0.35` | Center hole radius as a fraction of the total radius. `0` renders a full pie; `0.5` is a large donut. |
## Known limitations
- **Two and three levels only.** The provided templates handle 2-3 rings. Readability degrades past that, and deeper hierarchies need extra data sources and arc marks added to the template.
- **Arc angles are hard to compare precisely.** Sunburst shows composition well but not fine-grained size comparison. Use a bar or treemap chart when exact comparison matters.
- **Color follows Level 1.** Every ring inherits its top-level category color, so you cannot encode a second field through color without editing the template.
---
## Table Heatmap
A table heatmap colors each cell of a calendar grid by a measure value, mapping day of month across the x axis and month down the y axis. It is useful for spotting daily and seasonal patterns at a glance, where darker cells mean larger values.
- **Good for:** daily activity over a year (orders per day, logins per day), seasonal patterns, spotting unusually high or low days.
- **Not great for:** non-date dimensions, comparing exact values between cells, or data spanning more than one calendar year (months collapse together).
## Syntax
Use the following AML definition to add the Table Heatmap to your custom chart library.
```aml
CustomChartDef table_heatmap {
label: 'Table Heatmap'
description: 'To reveal patterns across days and months by coloring each cell of a calendar grid by a measure value.'
fields {
field date {
label: 'Date'
type: 'dimension'
data_type: 'date'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
template: @vgl {
"data": {
"values": @{values}
},
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "encodings": ["x", "y"], "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "encodings": ["x", "y"], "on": "mouseover", "clear": "mouseout"}}
],
"mark": {
"type": "rect"
},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"font": "Inter",
"axis": {
"domain": false,
"ticks": false,
"labelPadding": 8,
"labelColor": "#858B9E",
"labelFontSize": 11,
"titleColor": "#858B9E",
"titleFontSize": 11
},
"axisX": {
"format": "%e",
"labelAngle": 0
},
"view": {
"step": 13,
"strokeWidth": 0
},
"legend": {
"labelColor": "#858B9E",
"labelFontSize": 11,
"titleColor": "#858B9E",
"titleFontSize": 11
}
},
"encoding": {
"x": {
"type": "ordinal",
"field": @{fields.date.name},
"title": "Day",
"timeUnit": "date"
},
"y": {
"type": "ordinal",
"field": @{fields.date.name},
"title": "Month",
"timeUnit": "month"
},
"color": {
"type": "quantitative",
"field": @{fields.value.name},
"scale": {"scheme": "blues"},
"legend": {
"title": null
},
"aggregate": "max"
},
"tooltip": [
{"field": @{fields.date.name}, "type": "temporal", "timeUnit": "month", "title": "Month"},
{"field": @{fields.date.name}, "type": "temporal", "timeUnit": "date", "title": "Day"},
{"field": @{fields.value.name}, "type": "quantitative", "aggregate": "max", "title": "Value", "format": @{fields.value.format}, "formatType": "holisticsFormat"}
]
}
}
;;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field date {
type: 'dimension',
label: "Pick a date field",
}
field temperature {
type: 'measure',
label: "Then a number field",
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": {
"type": "rect",
"tooltip": true
},
"config": {
"axis": {
"domain": false
},
"view": {
"step": 13,
"strokeWidth": 0
}
},
"encoding": {
"x": {
"axis": {
"format": "%e",
"labelAngle": 0
},
"type": "ordinal",
"field": @{fields.date.name},
"title": "Day",
"timeUnit": "date"
},
"y": {
"type": "ordinal",
"field": @{fields.date.name},
"title": "Month",
"timeUnit": "month"
},
"color": {
"type": "quantitative",
"field": @{fields.temperature.name},
"legend": {
"title": null
},
"aggregate": "max"
}
}
}
;;
}
```
## Required fields
A Table Heatmap expects exactly two fields. Each row supplies one date and its measured value, and the template buckets those dates into a day-by-month grid.
| Field | Label | Type | Role |
|---------|-------|-------------|------|
| `date` | Date | `dimension` | Date field; its day of month sets the x position and its month sets the y position. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Drives the cell color, from light (low) to dark (high). Sorted descending (`apply_order: 2`). |
**Data requirements:** The `date` field must be a date type. The template takes `max` of `value` per day-month cell, so you don't need to pre-aggregate, though one row per day keeps the values exact.
**Sample data:**
| date | value |
|------------|-------|
| 2024-01-05 | 120 |
| 2024-01-18 | 86 |
| 2024-02-03 | 145 |
| 2024-02-21 | 60 |
| 2024-03-09 | 198 |
| 2024-03-27 | 110 |
## Known limitations
- **Date dimension only.** The x and y positions come from the day and month of one date field, so this chart cannot map two arbitrary dimensions.
- **One calendar year at a time.** The y axis uses month with no year component, so dates from different years stack onto the same row. Filter to a single year to keep cells distinct.
- **Cells show one aggregated value.** Each cell renders the `max` value for that day-month, so multiple rows on the same date collapse into a single colored cell rather than separate marks.
---
## Treemap
A treemap displays hierarchical data as a set of nested rectangles. Each branch of the tree is a rectangle whose area is proportional to a value in the data.
- **Good for:** part-to-whole composition across one category (revenue by product, storage by folder, headcount by team), comparing many categories by size in a compact space.
- **Not great for:** precise value comparison (use a bar chart), time series, flow between categories (use a Sankey Chart or Chord Diagram), or categories with negative values.

## Syntax
Use the following AML definition to add the Treemap to your custom chart library.
```aml
CustomChartDef treemap {
label: 'Treemap'
description: 'To show how categories make up a total as nested rectangles sized by value.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option fill_color {
label: 'Fill color'
type: 'color-picker'
default_value: '#255DD4'
}
option text_color {
label: 'Text color'
type: 'color-picker'
default_value: 'white'
}
option text_length_limit {
label: 'Text length limit (px)'
type: 'number-input'
default_value: 200
}
option layout_method {
label: 'Treemap method'
type: 'select'
default_value: 'squarify'
options: ['binary', 'squarify', 'dice', 'resquarify', 'slice', 'slicedice']
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 5" }]
},
{
"name": "height",
"init": "containerSize()[1] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 5" }]
},
{
"name": "normalPointSelection",
"value": null,
"on": [
{"events": "@rectMark:click", "update": "{'@{fields.dimension.name}': [datum['@{fields.dimension.name}']]}"},
{"events": "click[!event.item]", "update": "null"}
]
},
{
"name": "hoverPointSelection",
"value": null,
"on": [
{"events": "@rectMark:mouseover", "update": "{'@{fields.dimension.name}': [datum['@{fields.dimension.name}']]}"},
{"events": "@rectMark:mouseout", "update": "null"}
]
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"data": [
{
"name": "tree",
"values": @{values},
"transform": [
{
"type": "filter",
"expr": "datum['@{fields.value.name}'] !== null & datum['@{fields.value.name}'] > 0"
},
{
"type": "joinaggregate",
"ops": ["sum"],
"fields": [@{fields.value.name}],
"as": ["_total_size"]
},
{
"type": "joinaggregate",
"ops": ["max"],
"fields": [@{fields.value.name}],
"as": ["_max_size"]
},
{
"type": "formula",
"as": "_percentage",
"expr": "round(datum['@{fields.value.name}'] / datum._total_size * 10000)/100 + '%'"
},
{
"type": "formula",
"as": "_opacity",
"expr": "datum['@{fields.value.name}'] / datum._max_size"
},
{"type": "nest" },
{
"type": "treemap",
"field": @{fields.value.name},
"sort": {"field": @{fields.value.name}},
"round": true,
"method": @{options.layout_method.value},
"size": [{"signal": "width"}, {"signal": "height"}]
}
]
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"domain": {"data": "tree", "field": @{fields.dimension.name}},
"range": [@{options.fill_color.value}]
}
],
"marks": [
{
"type": "rect",
"name": "rectMark",
"from": {"data": "tree"},
"interactive": true,
"encode": {
"enter": {
"stroke": {"value": "white"},
"strokeWidth": {"value": 2},
"fill": {"scale": "color", "field": @{fields.dimension.name}},
"tooltip": {
"signal": "datum['@{fields.dimension.name}'] + ': ' + datum['@{fields.value.name}'] + ' (' + datum._percentage + ')'"
}
},
"update": {
"x": {"field": "x0"},
"y": {"field": "y0"},
"x2": {"field": "x1"},
"y2": {"field": "y1"},
"opacity": {"field": "_opacity"}
},
"hover": {"opacity": {"value": 1}}
}
},
{
"type": "text",
"from": {"data": "tree"},
"interactive": false,
"encode": {
"enter": {
"font": { "value": "Inter" },
"align": {"value": "center"},
"baseline": {"value": "middle"},
"fill": {"value": @{options.text_color.value}},
"text": {"field": @{fields.dimension.name}},
"limit": {"value": @{options.text_length_limit.value}}
},
"update": {
"x": {"signal": "0.5 * (datum.x0 + datum.x1)"},
"y": {"signal": "0.5 * (datum.y0 + datum.y1 - 15)"}
}
}
},
{
"type": "text",
"from": {"data": "tree"},
"interactive": false,
"encode": {
"enter": {
"font": { "value": "Inter" },
"fontSize": {"value": 13},
"fontWeight": {"value": 600},
"baseline": {"value": "top"},
"fill": {"value": @{options.text_color.value}},
"text": {"field": "_percentage"},
},
"update": {
"x": {"signal": "datum.x0 + 5"},
"y": {"signal": "datum.y0 + 10"}
}
}
}
],
"params": [
{
"bind": "..."
}
]
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field dimension {
label: 'Label'
type: 'dimension'
}
field measure {
label: 'Value',
type: 'measure'
}
}
options {
option fill_color {
label: 'Fill color'
type: 'color-picker'
default_value: '#255DD4'
}
option text_color {
label: 'Text color'
type: 'color-picker'
default_value: 'white'
}
option text_length_limit {
label: 'Text length limit (px)'
type: 'number-input'
default_value: 200
}
option layout_method {
label: 'Treemap method'
type: 'select'
default_value: 'squarify'
options: ['binary', 'squarify', 'dice', 'resquarify', 'slice', 'slicedice']
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "A flat treemap layout for hierarchical data.",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 5" }]
},
{
"name": "height",
"init": "containerSize()[1] - 5",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 5" }]
}
],
"data": [
{
"name": "tree",
"values": @{values},
"transform": [
{
"type": "filter",
"expr": "datum['@{fields.measure.name}'] !== null & datum['@{fields.measure.name}'] > 0"
},
{
"type": "joinaggregate",
"ops": ["sum"],
"fields": [@{fields.measure.name}],
"as": ["_total_size"]
},
{
"type": "joinaggregate",
"ops": ["max"],
"fields": [@{fields.measure.name}],
"as": ["_max_size"]
},
{
"type": "formula",
"as": "_percentage",
"expr": "round(datum['@{fields.measure.name}'] / datum._total_size * 10000)/100 + '%'"
},
{
"type": "formula",
"as": "_opacity",
"expr": "datum['@{fields.measure.name}'] / datum._max_size"
},
{"type": "nest" },
{
"type": "treemap",
"field": @{fields.measure.name},
"sort": {"field": @{fields.measure.name}},
"round": true,
"method": @{options.layout_method.value},
"size": [{"signal": "width"}, {"signal": "height"}]
}
]
}
],
"scales": [
{
"name": "color",
"type": "ordinal",
"domain": {"data": "tree", "field": @{fields.dimension.name}},
"range": [@{options.fill_color.value}]
}
],
"marks": [
{
"type": "rect",
"from": {"data": "tree"},
"interactive": true,
"encode": {
"enter": {
"stroke": {"value": "white"},
"strokeWidth": {"value": 2},
"fill": {"scale": "color", "field": @{fields.dimension.name}},
"tooltip": {
"signal": "datum['@{fields.dimension.name}'] + ': ' + datum['@{fields.measure.name}'] + ' (' + datum._percentage + ')'"
}
},
"update": {
"x": {"field": "x0"},
"y": {"field": "y0"},
"x2": {"field": "x1"},
"y2": {"field": "y1"},
"opacity": {"field": "_opacity"}
},
"hover": {"opacity": {"value": 1}}
}
},
{
"type": "text",
"from": {"data": "tree"},
"interactive": false,
"encode": {
"enter": {
"font": { "value": "Inter" },
"align": {"value": "center"},
"baseline": {"value": "middle"},
"fill": {"value": @{options.text_color.value}},
"text": {"field": @{fields.dimension.name}},
"limit": {"value": @{options.text_length_limit.value}}
},
"update": {
"x": {"signal": "0.5 * (datum.x0 + datum.x1)"},
"y": {"signal": "0.5 * (datum.y0 + datum.y1 - 15)"}
}
}
},
{
"type": "text",
"from": {"data": "tree"},
"interactive": false,
"encode": {
"enter": {
"font": { "value": "Inter" },
"fontSize": {"value": 13},
"fontWeight": {"value": 600},
"baseline": {"value": "top"},
"fill": {"value": @{options.text_color.value}},
"text": {"field": "_percentage"},
},
"update": {
"x": {"signal": "datum.x0 + 5"},
"y": {"signal": "datum.y0 + 10"}
}
}
}
],
"params": [
{
"bind": "..."
}
]
};;
}
```
## Required fields
A Treemap expects exactly two fields. Each row of input becomes one rectangle.
| Field | Label | Type | Role |
|-------------|-----------|-------------|------|
| `dimension` | Dimension | `dimension` | Category each rectangle represents; also drives the rectangle label. Sorted ascending (`apply_order: 1`). |
| `value` | Value | `measure` | Rectangle area, the percentage label, and the fill opacity (relative to the largest value). Sorted descending (`apply_order: 2`). |
**Data requirements:** Pre-aggregate to one row per category (for example, `SUM(value)` grouped by `dimension`); the template sizes a rectangle per row and does not combine duplicate categories. The template keeps only rows where `value` is non-null and greater than zero, so it drops any zero or negative rows before rendering.
**Sample data:**
| dimension | value |
|-------------|-------|
| Electronics | 24000 |
| Apparel | 18000 |
| Home | 12000 |
| Toys | 7000 |
| Books | 4000 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|---------------------|-------------|--------|
| `fill_color` | `#255DD4` | Base fill color for the rectangles. Each rectangle's opacity scales with its value relative to the largest one. |
| `text_color` | `white` | Color of the category label and percentage text inside each rectangle. |
| `text_length_limit` | `200` | Maximum width of the category label, in pixels. The template truncates longer labels. |
| `layout_method` | `squarify` | Treemap tiling algorithm that controls rectangle shapes and arrangement. |
## Known limitations
- **Pre-aggregate first.** The template draws one rectangle per row and does not sum duplicate categories, so repeated category rows render as separate overlapping rectangles. Aggregate to one row per category first.
- **The template drops non-positive values.** It filters out rows where `value` is null, zero, or negative, so categories with those values do not appear at all.
- **Single level only.** The template renders a flat set of rectangles from one dimension; it does not nest sub-categories. Use a Sunburst Chart for multi-level hierarchies.
---
## Trellis Chart
A Trellis plot (or small multiple) is a series of similar plots that displays different subsets of the same data, facilitating comparison across subsets.
- **Good for:** comparing the same bar breakdown across segments (sales by category per region, counts by stage per team), spotting which segments differ from the rest, laying out many small bar charts in a stacked grid of rows.
- **Not great for:** time-series x-axes (the template treats the x-axis as ordinal and may not handle dates well), a single segment (a plain bar chart is simpler), or part-to-whole composition (use a sunburst or treemap chart).

## Syntax
Use the following AML definition to add the Trellis Chart to your custom chart library.
```aml
// Generate comparative insights across segments
// 1) Select a row dimension to categorize data into segments,
// 2) Choose an x-axis dimension (may not work with time yet)
CustomChartDef trellis_chart {
label: 'Trellis Chart'
description: 'To display a series of similar small-multiple plots, one per subset, so you can compare patterns across segments.'
fields {
field facet {
label: 'Facet'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field x_axis {
label: 'X-axis'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 3
direction: 'desc'
}
}
}
options {
option show_y_axis_label {
label: 'Show Y axis label'
type: 'toggle'
default_value: true
}
option row_sort {
label: "Row Sort perference"
type: "select"
options: ['ascending','descending']
default_value: 'ascending'
}
option row_height {
label: "Height of the row"
type: "number-input"
default_value: '25'
}
option row_width {
label: "Width of the chart"
type: "number-input"
default_value: '800'
}
option row_space {
label: "Space between rows"
type: "number-input"
default_value: '25'
}
option fill_color {
label: 'Fill Color'
type: 'color-picker'
default_value: '#aaa'
}
}
template: @vgl {
"data": { "values": @{values}},
"transform": [
{
"window": [
{
"op": "mean",
"field": @{fields.value.name},
"as": "meanValue"
}
],
"frame": [null, null],
"groupby": [@{fields.facet.name}]
}
],
"facet": {
"row": {
"field": @{fields.facet.name},
"type": "nominal",
"sort": @{options.row_sort.value},
"header": {
"title": "@{fields.facet.name} (@{fields.value.name})"
}
}
},
"spacing": {"row": @{options.row_space.value}},
// "height": "container",
// "width": "container",
"spec": {
"layer": [
{
"height": @{options.row_height.value},
"width": @{options.row_width.value},
"mark": "bar",
"params": [
{"name": "normalPointSelection", "select": {"type": "point", "toggle": "true", "clear": "mouseup"}},
{"name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"}}
],
"encoding": {
"x": {
"field": @{fields.x_axis.name},
"title": @{fields.x_axis.name},
"type": "ordinal"
},
"y": {
"field": @{fields.value.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {
"title": null,
"labels": @{options.show_y_axis_label.value}
}
},
"color": {
"value": @{options.fill_color.value}
},
"tooltip": [
{"field": @{fields.facet.name}, "title": @{fields.facet.name}},
{"field": @{fields.x_axis.name}, "title": @{fields.x_axis.name}},
{"field": @{fields.value.name}, "title": @{fields.value.name}, "type": "quantitative"},
{"field": "meanValue", "title": "Mean for row", "type": "quantitative"}
]
}
}
]
},
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"background": null,
"view": {"stroke": null},
"font": "Inter",
"axis": {
"title": null,
"ticks": false,
"domain": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
},
"axisX": {"grid": false, "labelAngle": -45},
"axisY": {
"grid": true,
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"gridOpacity": 1,
"format": "~s"
},
"header": {
"labelAngle": 0,
"labelAlign": "left",
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelFontSize": 11,
"titleColor": "#858B9E",
"titleFontSize": 11
},
"bar": {"cornerRadius": 2}
}
};;
}
```
View on GitHub
Legacy syntax
```aml
// Generate comparative insights across segments
// 1) Select a row dimension to categorize data into segments,
// 2) Choose an x-axis dimension (may not work with time yet)
CustomChart {
fields {
// row
field row {
type: "dimension"
label: "Facet to group into rows"
}
// x axis
field x {
type: "dimension"
label: "X-axis (categorical) "
}
// measure
field measure {
type: "measure"
label: "Metric to measure"
}
}
options {
option show_y_axis_label {
label: 'Show Y axis label'
type: 'toggle'
default_value: true
}
option row_sort {
label: "Row Sort perference"
type: "select"
options: ['ascending','descending']
default_value: 'ascending'
}
option row_height {
label: "Height of the row"
type: "number-input"
default_value: '25'
}
option row_width {
label: "Width of the chart"
type: "number-input"
default_value: '800'
}
option row_space {
label: "Space between rows"
type: "number-input"
default_value: '25'
}
option fill_color {
label: 'Fill Color'
type: 'color-picker'
default_value: '#aaa'
}
}
template: @vgl {
"data": { "values": @{values}},
"transform": [
{
"window": [
{
"op": "mean",
"field": @{fields.measure.name},
"as": "meanValue"
}
],
"frame": [null, null],
"groupby": [@{fields.row.name}]
}
],
"facet": {
"row": {
"field": @{fields.row.name},
"type": "nominal",
"sort": @{options.row_sort.value},
"header": {
"title": "@{fields.row.name} (@{fields.measure.name})",
"labelAngle": 0,
"labelAlign": "left"
}
}
},
"spacing": {"row": @{options.row_space.value}},
// "height": "container",
// "width": "container",
"spec": {
"layer": [
{
"height": @{options.row_height.value},
"width": @{options.row_width.value},
"mark": "bar",
"encoding": {
"x": {
"field": @{fields.x.name},
"title": @{fields.x.name},
"type": "ordinal"
},
"y": {
"field": @{fields.measure.name},
"type": "quantitative",
"scale": {"zero": false},
"axis": {
"title": null,
"labels": @{options.show_y_axis_label.value},
"ticks": false,
"format": "~s"
}
},
"color": {
"value": @{options.fill_color.value}
},
"tooltip": [
{"field": @{fields.row.name}, "title": @{fields.row.name}},
{"field": @{fields.x.name}, "title": @{fields.x.name}},
{"field": @{fields.measure.name}, "title": @{fields.measure.name}, "type": "quantitative"},
{"field": "meanValue", "title": "Mean for row", "type": "quantitative"}
]
}
}
]
},
"config": {
"axis": {
"grid": false,
"domain": false
}
}
};;
}
```
## Required fields
A Trellis Chart expects exactly three fields. `facet` becomes one row of bars per value; within each row, `x_axis` and `value` draw the bars.
| Field | Label | Type | Role |
|----------|--------|-------------|------|
| `facet` | Facet | `dimension` | Splits the data into one row (small multiple) per value. Sorted ascending (`apply_order: 1`). |
| `x_axis` | X-axis | `dimension` | Categorical x position within each row. Sorted ascending (`apply_order: 2`). |
| `value` | Value | `measure` | Bar height (y). Sorted descending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per facet and x-axis combination; the template plots `value` directly as bars without summing. The template treats the x-axis as ordinal, so use categorical values rather than dates. The template also computes a per-row mean of `value` (shown in the tooltip as "Mean for row").
**Sample data:**
| facet | x_axis | value |
|--------|---------|-------|
| North | Q1 | 4200 |
| North | Q2 | 4800 |
| North | Q3 | 5100 |
| South | Q1 | 3100 |
| South | Q2 | 3400 |
| South | Q3 | 3000 |
## Options
Set these options to adjust the layout without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|---------------------|--------------|--------|
| `show_y_axis_label` | `true` | Toggles the y-axis labels in each row. |
| `row_sort` | `ascending` | Orders the facet rows ascending or descending. |
| `row_height` | `'25'` | Height of each row's plot, in pixels. |
| `row_width` | `'800'` | Width of the chart, in pixels. |
| `row_space` | `'25'` | Vertical gap between rows, in pixels. |
| `fill_color` | `#aaa` | Fill color of the bars. |
## Known limitations
- **X-axis is ordinal, not temporal.** The template treats dates on the x-axis as categories, so a true time axis may not render correctly. Use categorical x-values, or another chart for time series.
- **Width and height are fixed values, not container-fit.** `row_width` and `row_height` set static pixel sizes (the container-fit lines are commented out), so wide content can overflow or leave whitespace. Tune the size options to your layout.
- **Many facets make a tall chart.** Each facet value adds a row, so a large number of facets produces a long scroll. Filter to the segments you want to compare.
---
## Waterfall Chart
A waterfall chart visualizes how intermediate values contribute to a total, particularly the cumulative effect of sequential positive or negative changes. Common applications include financial statements, P&L analysis, budget variances, and sales funnels.
- **Good for:** profit-and-loss breakdowns, budget variance, revenue bridges, any running total built from sequential positive and negative steps.
- **Not great for:** part-to-whole composition without an order (use a treemap or sunburst), independent category comparison (use a bar chart), or time series with one value per period.
## Syntax
Use the following AML definition to add the Waterfall Chart to your custom chart library.
```aml
CustomChartDef waterfall_chart {
label: 'Waterfall Chart'
description: 'To show how a sequence of positive and negative changes builds up to a running total.'
fields {
field amount {
label: 'Amount'
type: 'measure'
sort {
apply_order: 1
direction: 'desc'
}
}
field label {
label: 'Label'
type: 'dimension'
sort {
apply_order: 2
direction: 'asc'
}
}
field sort_order {
label: 'Sort Order'
type: 'dimension'
data_type: 'number'
sort {
apply_order: 3
direction: 'asc'
}
}
}
options {
option begin_label {
label: 'Begin Label'
type: 'input'
default_value: ''
}
option end_label {
label: 'End Label'
type: 'input'
default_value: 'Total'
}
option bar_padding {
label: 'Bar Padding (0-0.9)'
type: 'number-input'
default_value: 0.3
}
option begin_end_color {
label: 'Begin - End Color'
type: 'color-picker'
default_value: '#BDBDBD'
}
option begin_end_text {
label: 'Begin - End Label Color'
type: 'color-picker'
default_value: 'black'
}
option positive {
label: 'Positive Color'
type: 'color-picker'
default_value: '#58A65C'
}
option negative {
label: 'Negative Color'
type: 'color-picker'
default_value: '#D85140'
}
option value_format {
label: 'Format'
type: 'input'
default_value: '$,.0f'
}
}
template: @vgl {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": @{values}
},
"transform": [
{"calculate": "datum['@{fields.amount.name}']", "as": "amount"},
{"calculate": "datum['@{fields.label.name}']", "as": "label"},
{"calculate": "datum['@{fields.sort_order.name}']", "as": "label_sorter"},
{
"window": [{"op": "sum", "field": "amount", "as": "sum"}],
"sort": [{"field": "label_sorter", "order": "ascending"}]
},
{
"window": [{"op": "lead", "field": "label", "as": "lead"}],
"sort": [{"field": "label_sorter", "order": "ascending"}]
},
{"calculate": "datum.lead === null ? '@{options.end_label.value}' : datum.lead", "as": "lead"},
{"calculate": "datum.label === '@{options.end_label.value}' ? 0 : datum.sum - datum.amount", "as": "previous_sum"},
{"calculate": "datum.label === '@{options.end_label.value}' ? datum.sum : datum.amount", "as": "amount"},
{
"calculate": "(datum.label !== '@{options.begin_label.value}' && datum.label !== '@{options.end_label.value}' && datum.amount > 0 ? '+' + format(datum.amount, '@{options.value_format.value}') : format(datum.amount, '@{options.value_format.value}'))",
"as": "text_amount"
},
{"calculate": "(datum.sum + datum.previous_sum) / 2", "as": "center"},
{"calculate": "datum.sum < datum.previous_sum ? datum.sum : ''", "as": "sum_dec"},
{"calculate": "datum.sum > datum.previous_sum ? datum.sum : ''", "as": "sum_inc"}
],
"encoding": {
"x": {
"field": "label",
"type": "ordinal",
"sort": {"field": "label_sorter", "order": "ascending"},
"scale": {"paddingInner": @{options.bar_padding.value}, "paddingOuter": 0.15},
"axis": {
"format": @{fields.label.format},
"formatType": "holisticsFormat"
}
}
},
"layer": [
{
"mark": {"type": "bar"},
"params": [
{
"name": "normalPointSelection",
"select": {
"type": "point",
"toggle": "true",
"clear": "mouseup"
}
},
{
"name": "hoverPointSelection",
"select": {
"type": "point",
"on": "mouseover",
"clear": "mouseout"
}
}
],
"encoding": {
"y": {"field": "previous_sum", "type": "quantitative", "format": @{fields.amount.format}, "formatType": "holisticsFormat"},
"y2": {"field": "sum"},
"color": {
"condition": [
{
"test": "datum.label === '@{options.begin_label.value}'",
"value": @{options.begin_end_color.value}
},
{"test": "datum.sum < datum.previous_sum", "value": @{options.negative.value}}
],
"value": @{options.positive.value}
},
"fillOpacity": {
"condition": {"param": "normalPointSelection", "value": 1},
"value": 0.3
},
"tooltip": [
{"field": "label", "title": @{fields.label.name}, "format": @{fields.label.format}, "formatType": "holisticsFormat"},
{"field": "amount", "title": @{fields.amount.name}, "format": @{options.value_format.value}},
{"field": "sum", "title": "Total", "format": @{options.value_format.value}}
]
}
},
{
"transform": [
{"filter": "datum.lead === '@{options.end_label.value}'"},
{"calculate": "datum.sum / 2", "as": "center_end"}
],
"encoding": {
"x": {
"field": "lead",
"sort": null,
"axis": {
"format": @{fields.label.format},
"formatType": "holisticsFormat"
}
},
"tooltip": [
{"field": "sum", "title": "Total", "format": @{options.value_format.value}}
]
},
"layer": [
{
"mark": {"type": "bar"},
"encoding": {
"y": {"field": "sum", "type": "quantitative"},
"color": {"value": @{options.begin_end_color.value}}
}
},
{
"mark": {"type": "text", "limit": {"expr": "bandwidth('x') * 1.8"}},
"encoding": {
"y": {"field": "center_end", "type": "quantitative"},
"text": {"field": "sum", "format": @{options.value_format.value}},
"color": {"value": @{options.begin_end_text.value}}
}
}
]
},
{
"mark": {
"type": "rule",
"color": @{options.begin_end_color.value},
"xOffset": {"expr": "bandwidth('x') / 2"},
"x2Offset": {"expr": "0 - bandwidth('x') / 2"}
},
"encoding": {
"x2": {"field": "lead"},
"y": {"field": "sum", "type": "quantitative"}
}
},
{
"mark": {"type": "text", "limit": {"expr": "bandwidth('x') * 1.8"}},
"encoding": {
"y": {"field": "center", "type": "quantitative"},
"text": {"field": "text_amount", "type": "nominal"},
"color": {
"condition": [
{
"test": "datum.label === '@{options.begin_label.value}' || datum.label === '@{options.end_label.value}'",
"value": @{options.begin_end_text.value}
}
],
"value": "white"
}
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
"contextMenuSignals": ["hoverPointSelection"]
},
"config": {
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"titleColor": "#858B9E",
"labelAngle": 0,
"labelOverlap": "parity",
"labelLimit": 70,
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisY": {
"domain": false
},
"text": {
"fontWeight": "bold",
"baseline": "middle"
},
"rule": {
"opacity": 1,
"strokeWidth": 1
},
"view": {
"opacity": 0
},
"font": "Inter"
}
};;
}
```
View on GitHub
Legacy syntax
```aml
CustomChart {
fields {
field amount {
type: 'measure'
}
field label {
type: 'dimension'
}
field label_sorter {
type: 'dimension'
}
}
options {
option begin_label {
label: 'Begin Label'
type: 'input'
default_value: ''
}
option end_label {
label: 'End Label'
type: 'input'
default_value: 'Total'
}
option bar_size {
label: 'Bar Size'
type: 'number-input'
default_value: 50
}
option begin_end_color {
label: 'Begin - End Color'
type: 'color-picker'
default_value: '#BDBDBD'
}
option begin_end_text {
label: 'Begin - End Label Color'
type: 'color-picker'
default_value: 'black'
}
option positive {
label: 'Positive Color'
type: 'color-picker'
default_value: '#58A65C'
}
option negative {
label: 'Negative Color'
type: 'color-picker'
default_value: '#D85140'
}
option value_format {
label: 'Format'
type: 'input'
default_value: '$,.0f'
}
}
template: @vgl {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": @{values}
},
"transform": [
{"calculate": "datum['@{fields.amount.name}']", "as": "amount"},
{"calculate": "datum['@{fields.label.name}']", "as": "label"},
{"calculate": "datum['@{fields.label_sorter.name}']", "as": "label_sorter"},
{
"window": [{"op": "sum", "field": "amount", "as": "sum"}],
"sort": [{"field": "label_sorter", "order": "ascending"}]
},
{
"window": [{"op": "lead", "field": "label", "as": "lead"}],
"sort": [{"field": "label_sorter", "order": "ascending"}]
},
{"calculate": "datum.lead === null ? '@{options.end_label.value}' : datum.lead", "as": "lead"},
{"calculate": "datum.label === '@{options.end_label.value}' ? 0 : datum.sum - datum.amount", "as": "previous_sum"},
{"calculate": "datum.label === '@{options.end_label.value}' ? datum.sum : datum.amount", "as": "amount"},
{
"calculate": "(datum.label !== '@{options.begin_label.value}' && datum.label !== '@{options.end_label.value}' && datum.amount > 0 ? '+' + format(datum.amount, '@{options.value_format.value}') : format(datum.amount, '@{options.value_format.value}'))",
"as": "text_amount"
},
{"calculate": "(datum.sum + datum.previous_sum) / 2", "as": "center"},
{"calculate": "datum.sum < datum.previous_sum ? datum.sum : ''", "as": "sum_dec"},
{"calculate": "datum.sum > datum.previous_sum ? datum.sum : ''", "as": "sum_inc"}
],
"params": [
{"name": "barSize", "value": @{options.bar_size.value}},
{"name": "ruleOffset", "expr": "barSize/2"}
],
"encoding": {
"x": {
"field": "label",
"type": "ordinal",
"sort": {"field": "label_sorter", "order": "ascending"},
"axis": {
"format": @{fields.label.format},
"formatType": "holisticsFormat"
}
}
},
"layer": [
{
"mark": {"type": "bar", "size": @{options.bar_size.value}},
"params": [
{
"name": "normalPointSelection",
"select": {
"type": "point",
"toggle": "true",
"clear": "mouseup"
}
}
],
"encoding": {
"y": {"field": "previous_sum", "type": "quantitative", "format": @{fields.amount.format}, "formatType": "holisticsFormat"},
"y2": {"field": "sum"},
"color": {
"condition": [
{
"test": "datum.label === '@{options.begin_label.value}'",
"value": @{options.begin_end_color.value}
},
{"test": "datum.sum < datum.previous_sum", "value": @{options.negative.value}}
],
"value": @{options.positive.value}
},
"fillOpacity": {
"condition": {"param": "normalPointSelection", "value": 1},
"value": 0.3
},
"tooltip": [
{"field": "label", "title": @{fields.label.name}, "format": @{fields.label.format}, "formatType": "holisticsFormat"},
{"field": "amount", "title": @{fields.amount.name}, "format": @{options.value_format.value}},
{"field": "sum", "title": "Total", "format": @{options.value_format.value}}
]
}
},
{
"transform": [
{"filter": "datum.lead === '@{options.end_label.value}'"},
{"calculate": "datum.sum / 2", "as": "center_end"}
],
"encoding": {
"x": {
"field": "lead",
"sort": null,
"axis": {
"format": @{fields.label.format},
"formatType": "holisticsFormat"
}
},
"tooltip": [
{"field": "sum", "title": "Total", "format": @{options.value_format.value}}
]
},
"layer": [
{
"mark": {"type": "bar", "size": @{options.bar_size.value}},
"encoding": {
"y": {"field": "sum", "type": "quantitative"},
"color": {"value": @{options.begin_end_color.value}}
}
},
{
"mark": {"type": "text", "fontWeight": "bold", "baseline": "middle"},
"encoding": {
"y": {"field": "center_end", "type": "quantitative"},
"text": {"field": "sum", "format": @{options.value_format.value}},
"color": {"value": @{options.begin_end_text.value}}
}
}
]
},
{
"mark": {
"type": "rule",
"color": @{options.begin_end_color.value},
"opacity": 1,
"strokeWidth": 1,
"xOffset": {"expr": "ruleOffset"},
"x2Offset": {"expr": "-ruleOffset"}
},
"encoding": {
"x2": {"field": "lead"},
"y": {"field": "sum", "type": "quantitative"}
}
},
{
"mark": {"type": "text", "fontWeight": "bold", "baseline": "middle"},
"encoding": {
"y": {"field": "center", "type": "quantitative"},
"text": {"field": "text_amount", "type": "nominal"},
"color": {
"condition": [
{
"test": "datum.label === '@{options.begin_label.value}' || datum.label === '@{options.end_label.value}'",
"value": @{options.begin_end_text.value}
}
],
"value": "white"
}
}
}
],
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"],
},
"config": {
"axis": {
"title": null,
"ticks": false,
"labelPadding": 10,
"labelFontSize": 11,
"labelColor": "#858B9E",
"labelAngle": 0,
"labelOverlap": "parity",
"labelLimit": 70,
"gridDash": [8, 3],
"gridColor": "#F4F6F8",
"domainColor": "#bec1cb"
},
"axisY": {
"domain": false
},
"view": {
"opacity": 0
},
"font": "Inter"
}
};;
}
```
## Required fields
A Waterfall Chart expects exactly three fields. Each row of input is one step in the sequence.
| Field | Label | Type | Role |
|--------------|------------|-------------|------|
| `amount` | Amount | `measure` | Signed change for the step. Positive values step up, negative values step down. Sorted descending (`apply_order: 1`). |
| `label` | Label | `dimension` | Step name shown on the x-axis. Sorted ascending (`apply_order: 2`). |
| `sort_order` | Sort Order | `dimension` | Numeric position that sets the left-to-right order of steps. Sorted ascending (`apply_order: 3`). |
**Data requirements:** Pre-aggregate to one row per step; the template computes the running total in order of `sort_order`, so every step needs a distinct sort value. Use signed amounts (positive for increases, negative for decreases). The template appends a final total bar automatically, so you don't add a total row yourself.
**Sample data:**
| amount | label | sort_order |
|--------|------------------|------------|
| 12000 | Starting balance | 1 |
| 4500 | New sales | 2 |
| -1800 | Refunds | 3 |
| 2200 | Upsells | 4 |
| -3100 | Churn | 5 |
## Options
Set these options to adjust the chart without editing the Vega template. The `CustomChartDef` block above declares each option's type and allowed values.
| Option | Default | Effect |
|-------------------|-----------|--------|
| `begin_label` | (empty) | Label of the step treated as the starting bar, colored with the begin/end color. |
| `end_label` | `Total` | Label used for the auto-generated final total bar. |
| `bar_padding` | `0.3` | Inner spacing between bars, from 0 to 0.9. Higher values make thinner bars. |
| `begin_end_color` | `#BDBDBD` | Fill color for the begin and end (total) bars. |
| `begin_end_text` | `black` | Text color for the labels on the begin and end bars. |
| `positive` | `#58A65C` | Fill color for steps that increase the running total. |
| `negative` | `#D85140` | Fill color for steps that decrease the running total. |
| `value_format` | `$,.0f` | Number format string applied to amounts and totals (d3-format syntax). |
## Known limitations
- **Sort order drives everything.** The template accumulates the running total in `sort_order` sequence, so missing or duplicate sort values produce a wrong or jumbled total. Give each step a unique, gap-free order.
- **The chart generates the total bar; don't add your own.** It appends a final bar named by `end_label`, so including your own total row double-counts it.
- **Label text identifies the begin and end bars.** A step becomes the start or total only when its `label` exactly matches `begin_label` or `end_label`. Mismatched text leaves those bars colored as ordinary steps.
---
## Understand Custom Chart
This document walks you through the components of a [custom chart definition](/docs/charts/custom-charts#step-1-define-a-custom-chart) and how they work together at runtime.
## High-level structure
A custom chart definition combines [Vega](https://vega.github.io/vega/) or [Vega-lite](https://vega.github.io/vega-lite/) specifications with Holistics's own syntax on top:
- **Holistics's specifications**: a small set of syntax that defines how end-users interact with the chart through the Data Exploration view. This includes:
- [Field definition](#field-definition): maps your dataset's fields to Vega chart axes and dimensions.
- [Option definition](#option-definition): maps Holistics styling options to Vega chart styling properties.
- **Vega/Vega-lite specifications**: defines how the visualization looks. For more details, see [Template definition](#chart-template-definition).
For example, a simple bar chart definition using Vega-lite:
```aml
CustomChartDef bar_chart {
label: 'Bar Chart'
// === Holistics's specifications ===
// Field definition
fields {
field x {
type: "dimension"
label: "Category"
}
}
// Option definition
options {
option tooltip {
type: "toggle"
label: "Show tooltip"
default_value: true
}
}
// === Vega-lite specifications ===
template: @vgl {
"data": {"values": @{values}},
"mark": {
"type": "bar",
"tooltip": @{options.tooltip.value}, // receive tooltip value (true/false) from 'options' object
},
"encoding": {
"x": {"bin": true, "field": @{fields.x.name},}, // receive field name from 'fields.x' object
"y": {"aggregate": "count"}
}
};;
}
```
Below is a visual guide showing how each component in the definition will appear in the Data Exploration view:
In the following sections, we will go deeper into each component.
### Field definition
This component is written in **Holistics Syntax**, and it is where you define how your dataset fields map to Vega chart's axes and dimensions. Holistics will read these configurations and prepare the field input boxes in the Data Exploration view.
In the example below, we have defined two fields. They will appear as two field input boxes in the Visualization's Setting tab:
```aml
fields {
field a {
type: "dimension"
label: "This is the label of the first field"
},
field b {
type: "measure"
label: "This is the label of the second field"
}
}
```
For more information about `fields` object's properties, please refer to the [Field Properties](/reference/aml/custom-chart#fields) doc.
### Option definition
This is written in **Holistics Syntax**. This is where you define the styling options your chart accepts, such as tooltip visibility, goal line value or histogram bin size customization, etc. These options can be found in **Styles tab** in **Data Exploration** view.
For example, you want to give users the option to toggle on/off tooltip. Your Tooltip Option could look something like below:
For more information about `option` object's properties, please refer to the [Option Properties](/reference/aml/custom-chart#options) doc.
### Chart template definition
This is written using **Vega-lite syntax** (`template: @vgl`) or **Vega syntax** (`template: @vg`). This dictates the visualization properties, such as the shapes to be used (bars, points, lines,...), which field is mapped to the X/Y axis, or styling details like line thickness, colors etc...
:::info Note
The examples in this doc use Vega-lite (`@vgl`). If you prefer Vega, use `@vg` instead, but note that the chart template structure will differ. Refer to the [official Vega documentation](https://vega.github.io/vega/docs/) for details.
:::
A Vega-lite chart is defined with three required properties:
1. `data`: specifies the data source of the chart
2. `mark`: specifies the shapes that you want to use and their styles
3. `encoding`: specifies the mapping between user-input fields and chart's dimensions
Other than these required properties, you can specify how end-users can interact with your chart using Vega-lite's [`params`](/reference/aml/custom-chart#params), and Holistics's [`holisticsConfig`](/reference/aml/custom-chart#holisticsconfig) property.
:::tip
For a full reference, check the [official Vega-lite documentation](https://vega.github.io/vega-lite/docs/). If you just need a quick refresher on how properties work within Holistics, see [Vega-lite Properties](/reference/aml/custom-chart#vega-lite-properties).
:::
## How Custom Chart works
Normally when defining a Vega-lite chart, the user needs to hard-code every detail of the chart before rendering it. For example, the data source of a chart must be a pre-defined JSON object, or a link to an external data file.

[Open the Chart in the Vega Editor](https://vega.github.io/editor/#/url/vega-lite/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykBaADZ04JAKyUAVhDYA7EABoQAEzjQATjRyZ289AEEABBBoIcguIaZJ1h2DcyGA7nRiHETOMtXLDypJhUiioBKKigxEiCDGpoANqgYSD6wUxoAEwAHAC+ColoIABCqWhiYrn56ADCJagALADMFSBJACK1AJwAjM1JAKK1mT15LQUAYrViTSNJAOK1XR29BQASgwDsy+gAkpPp2QC6uSDI6gDWBdbqwXCyUGzKNLJkaKAAHq8gAGY0cILKBRQSkwAE8cHACrI2AgnlFgkg3jQIJ9BEhPIJ9M8LGgAAzZY4gz4-P4A9BpYFgiHoACODCQsh0gR0pBA+OyQA)
However, in Holistics, the Vega-lite component receives values from the declared `fields` and `options` objects, so that charts can be created dynamically when users drag in a dataset field, or toggle a styling option in the Data Exploration view.
This feature is enabled by **string interpolation**. For example, we have a custom chart definition as below:
```aml
CustomChartDef bar_chart {
label: 'Bar Chart'
fields {
field a {
type: "dimension"
label: "Dimension Field" // label of the field input's box in Data Exploration view
sort {apply_order: 1 direction: "asc"}
}
field b {
type: "measure" // dataset fields put here will be aggregated
label: "Measure Field"
sort {apply_order: 2 direction: "desc"}
}
}
options {
option tooltip {
type: 'toggle'
label: 'Show tooltip'
default_value: true
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": {
"type": "bar",
"tooltip": @{options.tooltip.value},
},
"encoding": {
"x": {
"field": @{fields.a.name},
"type": "temporal",
},
"y": {
"field": @{fields.b.name},
"type": "quantitative"
}
}
};;
}
```
Suppose the user drags `Created_at` field into `Dimension Field` and `Revenue` field into `Measure Field`, and click **Get Result**. During runtime, the following things will happen:
- Holistics's engine queries the database according to the field combinations, and return the data. This data is inserted into the `values` property.
- Metadata of the fields such as name, data type, format... are passed into the designated places
- Value `true`/`false` returned by toggling the **Show tooltip** option in the Styles tab will also be passed in.
After that, a complete Vega-lite chart definition will be compiled:
```json
// Compiled Vega-lite chart definition
{
"data": {
"values": [
{"a": "01/01/2022", "b": 28},
{"a": "01/02/2022", "b": 55},
{"a": "01/03/2022", "b": 43},
]
},
fields: {
a: {
name: "Created_at" // value of @{fields.a.name} is passed in here
type: "temporal"
}
b: {
name: "Sum of Revenue" // because the field is aggregated
type: "quantitative"
}
},
options: {
tooltip: {
value: true // @{options.tooltip.value} returned `true` and is passed in here
},
}
}
```
### Reference syntax and string interpolation
To reference a property of a declared object and pass its value into the Vega-lite chart template, we use **dot notation** and **string interpolation** syntax:
```
@{object.object_name.object_property}
```
For example, to interpolate the name of a dataset field being dragged into field `a` (Dimension Field) into the chart template: `@{fields.a.name}`
Similar to field reference, to pass the value of an option to Vega-lite chart: `@{options.option_name}`
## Legacy custom chart definition
Before as-code support, custom charts used a `CustomChart` block defined through **Admin Settings > Custom Chart**. Only admins can create or manage them there.
It has the same structure as `CustomChartDef` - `fields`, `options`, and `template` work identically - but without a name identifier, `label`, or `description`:
```aml
CustomChart {
fields { ... }
options { ... }
template: @vgl { ... };;
}
```
---
## Customizing Chart’s Tooltips
## Introduction
Typically, when you move your cursor over a point on a chart, it shows you the value of that point.
However, there are occasions when you may want to add extra fields to the chart, providing users with additional context and details without cluttering the visualization. This is where the Customizable Tooltips function comes in.
## How to customize Chart's Tooltips
To customize the chart tooltip, simply drag a field into the **Tooltip** area or create a new field there. Once a field is added to the tooltip, hovering over a data point on the visualization will show the values for those fields.
All fields added to the **Tooltip** area will be treated as "measures". You can further customize a tooltip by changing the aggregation function as needed.
## Supported Visualizations
Tooltip section is only available for following charts:
- Line, Column, Bar, and Area Chart
- Filled Map, Point Map
- Pie, Funnel, and Pyramid Chart
- Scatter, and Bubble Chart
---
## Sorting Data in your Visualization
## Introduction
With Holistics Sort, you can put your data in alphabetical/numerical order to present it in a more meaningful way.
## How to sort
There are two ways to sort:
- Using the sorting function in Visualization Settings
- Using the sorting function when viewing the reports on the dashboard
### 1. Using the sorting function in Visualization Settings
To use the sorting function in visualization settings, click on the drop-down field below the **Sort** section and select the fields you want to sort by.
You can use the sorting function to sort your bar/line/column chart or use the hamburger menu () on the top right corner to sort.
> Note: When you save the report, all the sort settings will be saved as well.
### 2. Using the sorting function when viewing the reports on the dashboard
Holistics allows viewers to quickly sort tables and pivot tables when viewing the dashboard in two ways:
- Click on the Down arrow (⌄) next to the column's name. You can sort as many columns as you like. The sorted column will have an arrow icon and a number (indicating the sort order) next to its name.
- Click on the Sort icon on the upper right, then choose the column(s) you want to sort from the drop-down. You can change the sort order by dragging and dropping the field names up and down.
> Note: The sorting function on the dashboard only provides a temporary view for each viewer. It will be reset to the sort settings in visualization settings once the dashboard is refreshed.
## Sort mechanism
### Visualization types that support Sort
- Tabular visualization:
- Table
- Pivot table
- Non-tabular visualization:
- Area chart
- Line chart
- Column chart
- Bar chart
- Combination chart
- Pie chart
- Pyramid chart
- Funnel chart
### Sort and row limit execution order
By default, in both tabular and non-tabular visualizations, the sort will be applied **AFTER** row limit is applied
If you want to override the default behavior i.e. having the sort be applied **BEFORE** row limit, please reach out to us at `support@holistics.io`. At this time, only the sort-limit order in tabular visualizations can be overridden.
Therefore, after being overridden:
- Tabular visualizations: The sort is applied before the row limit
- Non-tabular visualizations: The sort is applied after row limit (same as before)
> Note: If you are sorting a business calculation field, the sort will be applied after the row limit is applied.
### Explanation for the default sort-limit order
By default, Holistics limits the number of rows by adding a LIMIT clause to the query that is sent to the database. After that, when you use the sorting function in Visualization Settings, the sort is applied directly to the chart without adding an ORDER BY clause to the generated SQL.
This also explains the caution in the [Limiting data](/docs/data-exploration#limiting-data) section of the docs.
### Sort direction
- Ascending order (text from A to Z, capital letter before lowercase letter), number from smallest to largest, and date from oldest to newest).
- Descending order (text from Z to A, lowercase letter to capital letter), number from largest to smallest, and date from newest to oldest).
Sorting is performed on the original data values, not the formatted values. For example, values like `1.01` and `1.04` that both format to `1` are still sorted differently based on their original values.
### Sort fields
You can sort by one or many fields at the same time. The fields must exist in Visualization Settings to be sorted.
> Note: If your chart is using the Date dimension in the X-axis, you can only sort by that dimension.
### Sort order
The sort order determines the sequence the data is sorted. For example: you want to sort your `Revenue by category and country` table by Country (ascending), then Category (descending). The returned results will display all the countries whose first letter goes from A to Z. Then, for the same country, all the categories whose first letter goes from Z to A will be displayed.
The vertical order of the fields in the **Sort** section in Visualization Settings indicates the sort order applied in the underlying query. In particular, the field with the upper position will be sorted first.
## FAQs
### Can I use the ORDER BY clause in the Query Model to override the default row-limit order?
**No**, you cannot use ORDER BY in the Query Model to override the default row-limit order.
The reason is that when Holistics builds a Query Model, SQL query inside the model is wrapped as a subquery. Depending on the database logic, **ORDER BY clause in a subquery may not be respected in the outer query**, which could lead to an unexpected result.
### Can I find the top/bottom N items using the sorting function?
In Holistics, we have a clear distinction between **top/bottom N** and **first/last N**:
- **Top/bottom N** items, based on a value, return the list of items with the values in the top N. There might be more than N items because some might share the same highest values.
- Example: Top 10 athletes by score might return 12 names because the top three athletes share the same score (i.e. 10, 10, 10, 9, 8...)
- **First/last N items** based on a value return the list of exactly N items with the highest/lowest values. There might be some items whose values belong to the top/bottom N but they will not be included.
- Example: First 10 athletes by score return exactly 10 names. Assuming that A and B share the same low score, only A is included in the list while B is not because A is the 10th name and B is the 11th name.
If you want to find the t**op/bottom N items** as defined above, we would recommend using the [Top/Bottom N feature](/docs/top-bottom-n-filter).
If you want to find the first/last N items, we would recommend using the sorting function with row limit in tables and pivot tables only. For non-tabular visualizations, the sort is applied after the row limit, therefore the final result is the first/last N items of the partial dataset, which is unlikely the correct result.
---
## Calendar Heatmap
A GitHub-style calendar heatmap that maps daily values onto a week-by-day grid with color intensity, perfect for spotting patterns across days and weeks.
**Best for**: Daily activity tracking, revenue patterns, engagement monitoring, contribution graphs
**Key techniques**: `map(rows)` loop, CSS Grid with dynamic `grid-column`/`grid-row` positioning, color buckets for intensity levels
## Template Code
```html
Calendar Heatmap
Each cell is a day; darker = higher value
Mon
Wed
Fri
Sun
{% map(rows) %}
{{ `Month Label` }}
{% end %}
{% map(rows) %}
{% end %}
Less
More
```
## How It Works
Each day is one row in your dataset. The template uses CSS Grid to place cells by their `Week Index` (column) and `Day Of Week` (row), creating the familiar calendar layout. Color intensity comes from a `Color Bucket` field (0-5) that you compute in your data query by normalizing values against the maximum.
**Layout structure:**
- A **month labels row** uses the same grid columns as the heatmap, but only shows a label on the first day of each month (controlled by `Month Label Display`)
- The **heatmap grid** places each cell at the correct week column and day-of-week row
- **Day-of-week labels** (Mon, Wed, Fri) sit in a fixed column to the left
- The whole grid scrolls horizontally for multi-month ranges
**Hover tooltips** are built with a CSS `::after` pseudo-element reading from the `data-tip` attribute. No JavaScript needed.
## Required Data Fields
| Field | Type | Slot | Description |
|-------|------|------|-------------|
| `Date` | Dimension | Row | Date truncated to day |
| `Day Of Week` | Dimension | Row | Integer 1-7 (Mon-Sun) |
| `Week Index` | Dimension | Row | Continuous week number across the date range |
| `Month Label` | Dimension | Row | Month name (e.g., "Jan 2025") |
| `Month Label Display` | Dimension | Row | CSS display value: `block` for 1st of month, `none` otherwise |
| `Revenue` | Measure | Value | The metric to visualize (any numeric value) |
| `Color Bucket` | Measure | Value | Integer 0-5 for color intensity level |
### Preparing the Calculated Fields
The `Day Of Week`, `Week Index`, `Month Label Display`, and `Color Bucket` fields need to be created as calculations. Here's an example using AML:
```aml
// Day of week (1 = Mon, 7 = Sun)
calculation day_of_week {
label: 'Day Of Week'
formula: @aql cast(date_part('dow', orders.created_at) + 1, 'int');;
calc_type: 'dimension'
data_type: 'number'
}
// Continuous week index
calculation week_index {
label: 'Week Index'
formula: @aql cast(date_part('week', orders.created_at), 'int');;
calc_type: 'dimension'
data_type: 'number'
}
// Show month label only on first day of each month
calculation month_label_display {
label: 'Month Label Display'
formula: @aql case(
when: date_part('day', orders.created_at) == 1, then: 'block',
else: 'none'
);;
calc_type: 'dimension'
data_type: 'text'
}
// Normalize value into 0-5 buckets using window_max
calculation color_bucket {
label: 'Color Bucket'
formula: @aql case(
when: sales_model.revenue == 0, then: 0,
when: (sales_model.revenue * 1.0) / window_max(sales_model.revenue) < 0.2, then: 1,
when: (sales_model.revenue * 1.0) / window_max(sales_model.revenue) < 0.4, then: 2,
when: (sales_model.revenue * 1.0) / window_max(sales_model.revenue) < 0.6, then: 3,
when: (sales_model.revenue * 1.0) / window_max(sales_model.revenue) < 0.8, then: 4,
else: 5
);;
calc_type: 'measure'
data_type: 'number'
}
```
### Important Settings
- **Pagination size must exceed total rows** (e.g., set to 1000+ for a full year of data). If pagination is too low, only the first page of days will render and later months will be missing.
- **Sort by Date ascending** so days appear in the correct order.
- **Number formatting** for `Day Of Week` and `Week Index` should use `inherited` (no decimal places) since they're used for CSS grid positioning.
## Customization Tips
### Change the color scheme
Replace the `.lvl-*` background colors to match your brand. For example, a blue scheme:
```css
.hm__cell.lvl-1 { background: #dbeafe; }
.hm__cell.lvl-2 { background: #93c5fd; }
.hm__cell.lvl-3 { background: #60a5fa; }
.hm__cell.lvl-4 { background: #2563eb; }
.hm__cell.lvl-5 { background: #1d4ed8; }
```
Remember to update the `.hm__swatch` legend colors to match.
### Adjust grid size
Change the `--cell` and `--gap` CSS variables to make cells larger or smaller:
```css
.hm {
--cell: 18px; /* smaller cells */
--gap: 4px; /* tighter spacing */
}
```
### Extend to a full year
The grid defaults to `repeat(60, ...)` columns which fits ~14 months. For a full year, 53 columns is sufficient. If you need more, increase the repeat count and the `min-width` on `.hm__grid`.
### Add more color buckets
You can increase granularity by adding more levels (e.g., `lvl-6` through `lvl-9`) and adjusting the bucket calculation thresholds accordingly.
---
## Interactive Control Buttons
Control Buttons let dashboard viewers filter data by clicking styled buttons, without relying on the standard filter panel. When a button is clicked, it cross-filters other visualizations on the page.
They're built inside a [**Dynamic Content block**](/docs/charts/dynamic-content-block), where you write HTML/CSS to control the appearance and a small template tag to wire up the filtering logic.
## How to set it up
### Step 1: Add a Dynamic Content block and configure its data
- Add a **dynamic markdown** visualization to your dashboard.
- In the configure panel, add the field you want to filter by to **Rows**.
This queries all distinct values from your data, each one will become a button.
### Step 2: Loop through the rows
Then, in the **Editor** tab, use `{% map(rows) %}` to loop through those rows:
```html
{% map(rows) %}
{% end %}
```
### Step 3: Wrap each button with ``
Inside that loop, wrap each item with ``. This is what makes each button clickable and triggers cross-filtering on other charts. Your template should now look like this:
```html
{% map(rows) %}
{{ `Your Field`.formatted }}
{% end %}
```
- `row="0"` tells `` which column group to filter on. For single-field filters (the most common case), this is always `0`.
- ``value="{{ `Your Field`.raw }}"`` is the raw value passed as the filter
- ``{{ `Your Field`.formatted }}`` is the display text shown on the button
- When selected, the `.h-drill-selected` class is added to the button element
### Step 4: Connect to other dashboard components
Once your buttons are in place, other charts on the dashboard will cross-filter automatically as long as they use the same field as the button block. If a visualization isn't responding, make sure its dataset includes that same field.
---
## Examples
Both examples below are drop-in starting points. Swap in your own field names and adjust the CSS to match your brand.
### Category buttons (country filter)
Filters the dashboard by country. Requires two fields: `Country` (the filter value) and `Country Icon` (a flag emoji for display).
```html
{% map(rows) %}
{{ `Country Icon`.formatted }}
{{ `Country`.formatted }}
{% end %}
```
**Required data fields**
| Field | Type | Description |
|-------|------|-------------|
| `Country` | Dimension | Filter value (e.g. country name) |
| `Country Icon` | Dimension | Display icon (e.g. flag emoji) |
Sample data: [sample-data-control-buttons.csv](https://media.holistics.io/9ba3a22a-sample-data-control-buttons.csv)
```csv
Country,Country Icon
Germany,🇩🇪
Singapore,🇸🇬
United States,🇺🇸
Vietnam,🇻🇳
```
### Quarter selector buttons (date filter)
Filters the dashboard by quarter. Add your date field to Rows and set the time-grain to **Quarter**. The block queries all distinct quarters from your data, so the buttons always reflect what's in the dataset.
```html
Filter by quarter →
{% map(rows) %}
{{ `Quarter`.formatted }}
{% end %}
```
**Required data field**
| Field | Type | Description |
|-------|------|-------------|
| `Quarter` | Date dimension | Add to Rows with time-grain set to Quarter |
## Customization tips
Once the buttons are working, you can adjust their appearance to match your dashboard's design without touching the filtering logic.
- **Colors**: Change the border and background colors to match your brand
- **Hover/selected states**: Adjust `:hover` and `.h-drill-selected.your-button-class` (both classes must be on the same element)
- **Richer buttons**: Add metrics, descriptions, or icons by extending the card HTML
---
## Dumbbell Chart
A custom visualization comparing two related metrics side-by-side, perfect for showing gaps between values like cart additions vs. purchases.
**Best for**: Funnel analysis, before/after comparisons, gap visualization
**Key techniques**: `map(rows)` loop, percentage calculations for positioning, CSS-based chart rendering
## Template Code
```html
Purchased
Added to Cart
{% map(rows) %}
{{ `Product name` }}
{{ `Purchased` }}
{{ `Added to cart` }}
{% end %}
```
## Required Data Fields
| Field | Type | Description |
|-------|------|-------------|
| `Product name` | Dimension | Row label |
| `Purchased` | Measure | Start value (left dot) |
| `Added to cart` | Measure | End value (right dot) |
| `Start Percent` | Calculation | Position of start dot (0-100) |
| `End Percent` | Calculation | Position of end dot (0-100) |
| `Bar Width` | Calculation | Width of connecting bar |
## Sample Data
Import this data into Holistics to use: [sample-data-dumbbell.csv](https://media.holistics.io/4455057c-sample-data-dumbbell.csv)
```csv
Product name,Added to cart,Purchased,Start Percent,End Percent,Bar Width
Protein Powder,490,310,63.27,100,36.73
Dumbbells,520,500,96.15,100,3.85
Wireless Headphones,500,420,84.00,100,16.00
Water Bottle,520,350,67.31,100,32.69
Running Shoes,480,380,79.17,100,20.83
Resistance Bands,500,260,52.00,100,48.00
Yoga Mat,500,290,58.00,100,42.00
Jump Rope,480,420,87.50,100,12.50
Fitness Tracker,500,450,90.00,100,10.00
Exercise Bike,500,180,36.00,100,64.00
```
The percentage fields are calculated as: `Start Percent = (Purchased / Added to cart) * 100`, `End Percent = 100`, `Bar Width = End Percent - Start Percent`.
## Customization Tips
- Change the gradient colors in `.dumbbell-line` to match your brand
- Adjust dot colors (`.dumbbell-dot.start`, `.dumbbell-dot.end`) for different comparisons
- Modify the legend text to match your metric names
- The percentage calculations should normalize your values to a 0-100 scale
---
## Executive Insights
Transform raw data into actionable narrative summaries with color-coded badges highlighting key metrics, trends, and opportunities.
**Best for**: Executive dashboards, funnel analysis, performance reviews
**Key techniques**: Row indexing (`rows[0]`, `rows[-1]`), conditional badge styling, narrative text generation
## Template Code
```html
Top Performers:
{{ rows[0].`Product name` }} ({{ rows[0].`Purchased` }}),
{{ rows[1].`Product name` }} ({{ rows[1].`Purchased` }}),
and {{ rows[2].`Product name` }} ({{ rows[2].`Purchased` }}) lead in actual sales conversions.
Largest Cart Abandonment:
{{ rows[-1].`Product name` }} shows the biggest gap with {{ rows[-1].`Cart abandonment` }} abandoned
({{ rows[-1].`Added to cart` }} added vs. {{ rows[-1].`Purchased` }} purchased),
followed by {{ rows[-2].`Product name` }} ({{ rows[-2].`Cart abandonment` }})
and {{ rows[-3].`Product name` }} ({{ rows[-3].`Cart abandonment` }} gap).
High Conversion Rate:
{{ rows[0].`Product name` }} demonstrates the strongest purchase intent with only a {{ rows[0].`Cart abandonment` }} unit gap,
while {{ rows[1].`Product name` }} ({{ rows[1].`Cart abandonment` }})
and {{ rows[2].`Product name` }} ({{ rows[2].`Cart abandonment` }} gap) also show relatively strong conversion.
Opportunity Area: Products with ≥{{ rows[-3].`Cart abandonment` }} gap suggest potential for targeted remarketing campaigns or checkout optimization.
```
## Required Data Fields
| Field | Type | Description |
|-------|------|-------------|
| `Product name` | Dimension | Product or item name |
| `Purchased` | Measure | Number of completed purchases |
| `Added to cart` | Measure | Number of cart additions |
| `Cart abandonment` | Measure | Gap between cart and purchase |
## Sample Data
Import this data into Holistics to use: [sample-data-insights.csv](https://media.holistics.io/1ce02666-sample-data-insights.csv)
```csv
Product name,Added to cart,Purchased,Cart abandonment
Dumbbells,520,500,20
Fitness Tracker,500,450,50
Jump Rope,480,420,60
Wireless Headphones,500,420,80
Running Shoes,480,380,100
Water Bottle,520,350,170
Protein Powder,490,310,180
Yoga Mat,500,290,210
Resistance Bands,500,260,240
Exercise Bike,500,180,320
```
## Customization Tips
- Adjust the badge colors (`.insights-badge.red`, `.insights-badge.green`) to match your brand
- Modify the narrative text to fit your specific use case
- Add more rows by referencing `rows[3]`, `rows[4]`, etc.
- Use negative indexing (`rows[-1]`) to always reference the last items regardless of data size
---
## Template Gallery
Browse our collection of ready-to-use templates. Each example includes the complete code you can copy and customize for your own dashboards.
!["Overview"].includes(item.label)
)}
/>
## Contribution
Have a template to share? Post it on [Holistics Community](https://community.holistics.io/) so others can see it too!
---
## Metrics Tree
Visualize how your metrics break down from a North Star into its underlying drivers. Makes metric dependencies and relationships easy to scan for your whole team.
**Best for**: KPI decomposition, metric dependency mapping, business driver analysis
**Key techniques**: `col_totals` for aggregated values, CSS Grid multi-level layout, pseudo-element connectors
:::tip Sparklines coming soon
We're working on a sparkline component that will let you embed inline trend charts inside each node. Once available, you'll be able to show how each metric is trending right alongside its current value.
:::
## Template Code
```html
North Star
{{ col_totals.`North Star`.formatted }}
Primary outcome
Revenue
{{ col_totals.`Revenue` }}
Money from customers
Cost
{{ col_totals.`Cost` }}
Money paid out
Buyers
{{ col_totals.`Buyers` }}
People who purchased
AOV
{{ col_totals.`Aov` }}
Avg order value
COGS
{{ col_totals.`Cogs` }}
Cost to make
Shipping
{{ col_totals.`Shipping` }}
Cost to deliver
Sessions
{{ col_totals.`Sessions` }}
Traffic
Conversion Rate
{{ col_totals.`Conversion Rate` }}
Sessions → buyers
```
## How It Works
The tree is built with pure CSS Grid. No JavaScript needed. Each level is a grid row, and connector lines between levels are drawn using CSS pseudo-elements (`:before` and `:after`).
**Layout structure:**
- Each level (`level-1` through `level-4`) is a CSS Grid with a set number of columns
- Between levels, a `.lines` row draws the branching connectors
- Empty `.empty` divs act as spacers to position nodes correctly under their parent
**Data access:**
- All values use `col_totals` to pull aggregated totals across the entire dataset
- This means the tree shows a single rolled-up number per metric, not row-by-row data
## Required Data Fields
| Field | Type | Slot | Description |
|-------|------|------|-------------|
| `Month` | Dimension | Row | Time dimension for aggregation |
| `North Star` | Measure | Value | Top-level metric (e.g., Profit) |
| `Revenue` | Measure | Value | Revenue metric |
| `Cost` | Measure | Value | Total cost metric |
| `Buyers` | Measure | Value | Number of buyers |
| `Aov` | Measure | Value | Average order value |
| `Cogs` | Measure | Value | Cost of goods sold |
| `Shipping` | Measure | Value | Shipping costs |
| `Sessions` | Measure | Value | Number of sessions |
| `Conversion Rate` | Measure | Value | Sessions-to-buyers conversion rate |
### Setting Up the Block
In the Visualization Block definition, add the time dimension as a **Row** field and all metrics as **Value** fields with `aggregation: 'sum'`. Enable **Show Column Total** in the block settings. the template reads from `col_totals` to display the aggregated values.
## Sample Data
Import this data into Holistics to use: [sample-data-metrics-tree.csv](https://media.holistics.io/eb208343-metrics-tree.csv)
```csv
Month,North Star,Revenue,Cost,Buyers,AOV,COGS,Shipping,Sessions,Conversion Rate
2024-01,245149,352188,107039,7322,48.1,79692,27347,239226,0.0306
2024-02,265628,382769,117141,7996,47.87,86680,30461,262095,0.0305
2024-03,299244,431517,132273,9020,47.84,97050,35223,290974,0.031
2024-04,335063,482296,147233,9769,49.37,107089,40144,318913,0.0306
2024-05,342112,490557,148445,9774,50.19,107249,41196,339084,0.0288
2024-06,343323,489927,146604,9597,51.05,105620,40984,346630,0.0277
2024-07,320203,454882,134679,8921,50.99,97219,37460,340021,0.0262
2024-08,293177,415243,122066,8038,51.66,88673,33393,321534,0.025
2024-09,274734,388849,114115,7574,51.34,83640,30475,296710,0.0255
2024-10,250143,354674,104531,7061,50.23,77305,27226,272909,0.0259
2024-11,239129,340346,101217,6811,49.97,75343,25874,257353,0.0265
2024-12,250370,358061,107691,7203,49.71,80355,27336,255162,0.0282
```
## Customization Tips
### Adapt the tree to your metrics
This template uses an e-commerce profit tree as an example, but you can adapt it to any metric hierarchy. Replace the metric names, labels, and subtexts to match your business:
- **SaaS**: MRR → New MRR + Expansion - Churn → Leads × Conversion Rate
- **Marketplace**: GMV → Buyers × AOV → Traffic × Conversion × Average Basket
- **Content**: Engagement → Views × Time on Page → Impressions × CTR
### Change node colors
The `.pos` and `.neg` classes add green and red accent borders. Adjust the colors or add new classes:
```css
.metric-tree-v1 .pos {
border-color: rgba(16, 185, 129, 0.35); /* green */
}
.metric-tree-v1 .neg {
border-color: rgba(239, 68, 68, 0.35); /* red */
}
/* Add a neutral accent */
.metric-tree-v1 .neutral {
border-color: rgba(59, 130, 246, 0.35); /* blue */
}
```
### Add or remove levels
To add a 5th level, create a new `.level-5` grid rule and a `.lines-4` connector row:
```css
.metric-tree-v1 .level-5 {
grid-template-columns: repeat(6, var(--double-w));
}
.metric-tree-v1 .lines-4 {
grid-template-columns: repeat(5, var(--double-w));
}
```
Then add the corresponding HTML for the new nodes and connectors.
### Adjust node sizing
The node width is controlled by a single CSS variable. Change the `clamp()` values to make nodes wider or narrower:
```css
--node-w: clamp(100px, 12vw, 150px); /* min, preferred, max */
```
---
## Product Cards Grid
A responsive grid of product cards with images, metrics, and clean layouts for displaying multiple items at a glance.
**Best for**: E-commerce dashboards, product catalogs, inventory displays
**Key techniques**: CSS Grid responsive layout, image handling with `.raw`, looped card generation
## Template Code
```html
{% map(rows) %}
{{ `Name` }}
Revenue
{{ values.`Revenue` }}
Category
{{ `Category Name` }}
Price
{{ values.`Total Price` }}
{% end %}
```
## Required Data Fields
| Field | Type | Description |
|-------|------|-------------|
| `Name` | Dimension | Product name |
| `Product Image Url` | Dimension | URL to product image |
| `Category Name` | Dimension | Product category |
| `Revenue` | Measure | Revenue metric |
| `Total Price` | Measure | Price metric |
## Sample Data
Import this data into Holistics to use: [sample-data-card-grid.csv](https://media.holistics.io/d1a61ae6-sample-data-card-grid.csv)
```csv
Name,Product Image Url,Category Name,Total Price,Revenue
Samsung Bluetooth Earphone,https://m.media-amazon.com/images/I/61xtSvzyi1L._AC_UL320_.jpg,Audio Gadgets,32615,$102414.00
Sony Headphone 1,https://m.media-amazon.com/images/I/61qzLC0pG+L._AC_UY218_.jpg,Audio Gadgets,29529,$96082.00
Face Cream,https://m.media-amazon.com/images/I/61El1UzIVVL._AC_UL320_.jpg,Face,8832,$107831.00
Playstation 4,https://media.holistics.io/j4wlo8-Group-2884.png,Gaming,31842,$126679.00
Sony Xperia 1,https://media.holistics.io/tdcmvm-Group-2882.png,Mobiles,39826,$169948.00
Apple Watch 1,https://media.holistics.io/1pm8rk-Group-2887.png,Smartwatches,24887,$128789.00
```
## Customization Tips
- Adjust the grid breakpoints in `@media` queries for different responsive behavior
- Change the `.revenue-badge` colors to highlight different value ranges
- Add more detail rows by duplicating the `.deal-details-inner` block
- Use `.raw` for image URLs to ensure proper rendering
---
## Retention Heatmap
:::info Options to build a retention heatmap in Holistics
We support multiple ways to build a retention heatmap. Here's a quick comparison to help you pick the right one:
| Option | Custom colors | Auto color scale* | Maintenance |
|--------|---------------|-------------------|-------------|
| [Built-in, legacy Retention Heatmap](/docs/charts/cohort-retention) | No | Yes | Low -- built-in visualization |
| [Pivot Table](/as-code/aql/cookbook/aql-cohort-retention) with conditional formatting *(recommended)* | Yes | Coming soon | Low -- built-in visualization |
| **Dynamic Content Block** *(this page)* | Yes | Yes | Higher -- you maintain the HTML/CSS yourself |
**(*) Auto color scale**: Color intensity adjusts automatically to the current data range. Without it, a heatmap built for a max of 1,000 will look washed out when a user's data only goes up to 80, or when a time filter reduces the range.
:::
A cohort retention heatmap where the color intensity dynamically adjusts based on actual data values, and you have full control over the color scheme (combining the best of both built-in options).
**Best for**: Cohort retention analysis, user engagement tracking, subscription churn monitoring
**Key techniques**: Pivot-style `map(columns)` + nested `map(values)`, CSS custom properties for dynamic color, `hsl()` color calculations
## How this template handles auto color scale
A fixed color scale causes problems when your data range varies (either across different users or different time filters):
- **Across different users**: If your color scale is fixed at max = 1,000, Client A with max = 1,000 looks great. But Client B with max = 80 sees a washed-out heatmap because the scale wasn't built for their data range.
- **Across different time filters**: With a "last year" filter, max = 1,000 fills the darkest color. Switch to "last month" and max drops to 200: but it renders as a pale shade because the scale was designed for 1,000.
This template solves both by adding a `Heat Intensity` field to your data query, normalized to a 0–1 range. The CSS then maps this value to color intensity, so the darkest color always reflects your current maximum.
## Template Code
```html
Cohort Month
Cohort Size
Month Number
{% map(columns) %}
{{ `Month Number` }}
{% end %}
{% map(rows) %}
{{ `Cohort Month` }}
{{ `Users Cohort Size` }}
{% map(values) %}
{{ value.`Total Users` }}
{% end %}
{% end %}
```
## How the Dynamic Color Scale Works
The color magic happens through CSS custom properties and `hsl()` calculations. Here's how the pieces fit together:
**1. You set the base color once** at the container level:
```css
.cohort-container {
--hue: 201; /* Blue -- change this to pick your color */
--saturation: 96%; /* Color richness */
}
```
**2. Each cell receives a `--percentage` value** (0 to 1) from your data via inline style:
```html
```
**3. CSS calculates the lightness dynamically:**
```css
--bg-lightness: calc(95 - var(--percentage) * 50);
/* percentage = 0 → lightness 95% (nearly white) */
/* percentage = 1 → lightness 45% (deep color) */
background-color: hsl(var(--hue) var(--saturation) calc(var(--bg-lightness) * 1%));
```
The text color also adjusts (darker text on light cells, lighter text on dark cells) so values stay readable at any intensity.
Since `--percentage` comes from a normalized field in your query (`value / max_value`), the scale always maps your current data range to the full color spectrum, regardless of the absolute numbers.
## Required Data Fields
| Field | Type | Slot | Description |
|-------|------|------|-------------|
| `Cohort Month` | Dimension | Row | The cohort period (e.g., "Jan 2024") |
| `Users Cohort Size` | Dimension | Row | Number of users in the cohort |
| `Month Number` | Dimension | Column (pivot) | Duration period (0, 1, 2, ...) |
| `Total Users` | Measure | Value | Count of returning users per cell |
| `Heat Intensity` | Measure | Value | Normalized value between 0 and 1 for color intensity |
### Preparing the `Heat Intensity` Field
The `Heat Intensity` field drives the color scale. It should be a value between 0 and 1, where 1 = the maximum in your current data.
In AML, you can create a calculated measure using `window_max` to normalize against the current maximum:
```aml
calculation heat_intensity {
label: 'Heat Intensity'
formula: @aql case(
when: window_max(total_users) == 0,
then: null,
else: (total_users * 1.0) / window_max(total_users)
);;
calc_type: 'measure'
data_type: 'number'
}
```
This divides each cell's value by the maximum value across the entire grid. Since `window_max` recalculates when filters change, the color scale automatically adjusts when users switch time filters or other dashboard filters.
### Preparing the `Users Cohort Size` Field
To repeat the cohort size for each row, use `dimensionalize` to turn a measure into a row-level dimension:
```aml
calculation users_cohort_size {
label: 'Users Cohort Size'
formula: @aql dimensionalize(
total_users,
ecommerce_users.cohort_month
);;
calc_type: 'dimension'
data_type: 'number'
model: ecommerce_users
}
```
## Customization Tips
### Change the color scheme
Adjust `--hue` and `--saturation` in `.cohort-container` to any color you want:
| Color | `--hue` | `--saturation` |
|-------|---------|----------------|
| Blue (default) | `201` | `96%` |
| Green | `142` | `70%` |
| Purple | `270` | `80%` |
| Orange | `25` | `90%` |
| Teal | `175` | `70%` |
### Adjust the intensity range
The lightness formula `calc(95 - var(--percentage) * 50)` means:
- **95** = lightest value (when percentage = 0)
- **50** = the range of lightness variation
To make the contrast more dramatic, increase the range (e.g., `* 60`). To make it subtler, decrease it (e.g., `* 30`).
### Display retention percentage instead of raw counts
The template displays `Total Users` (raw count) in each cell, but you can also add a retention percentage measure and display that instead. Create a calculated measure using `of_all`:
```aml
calculation total_users_retention {
label: 'Retention Rate'
formula: @aql (total_users * 1.0)
/ (total_users | of_all(ecommerce_orders.month_number, keep_filters: true));;
calc_type: 'measure'
data_type: 'number'
}
```
Then swap the displayed value in the template:
```html
{{ value.`Retention Rate` }}
```
### Add row/column totals
You can add totals using the `row_total` and `col_totals` accessors from the [Syntax Reference](../syntax-reference.md#accessing-pivot-total-values). For example, add a totals column at the end of each row:
```html
{{ `row_total`.`Total Users` }}
```
---
## User Profile Card
A detailed profile view combining images, metrics, and contact information in a polished card layout.
**Best for**: CRM dashboards, customer support tools, user management interfaces
**Key techniques**: Profile images, metric cards, external links (email, maps), status badges with dynamic classes
## Template Code
```html
{{ rows.0.`Full Name` }}
✉️
{{ rows.0.`Email` }}
📍
{{ rows.0.`Location` }}
{{ rows.0.values.`Total Orders` }}
Total Orders
{{ rows.0.values.`Lifetime Value` }}
Lifetime Value
{{ rows.0.values.`Avg Order Value` }}
Avg Order
Status
{{ rows.0.`Status` }}
Member Since
{{ rows.0.`Member Since` }}
Last Order
{{ rows.0.`Last Order Date` }}
User ID
{{ rows.0.`User ID` }}
```
## Required Data Fields
| Field | Type | Description |
|-------|------|-------------|
| `Full Name` | Dimension | User's full name |
| `Email` | Dimension | Email address |
| `Location` | Dimension | City or address |
| `Profile Image URL` | Dimension | URL to profile photo |
| `Status` | Dimension | Status value (e.g., "active", "vip", "new") |
| `Member Since` | Dimension | Registration date |
| `Last Order Date` | Dimension | Most recent order date |
| `User ID` | Dimension | Unique identifier |
| `Total Orders` | Measure | Order count |
| `Lifetime Value` | Measure | Total customer value |
| `Avg Order Value` | Measure | Average order amount |
## Sample Data
Import this data into Holistics to use: [sample-data-record-lookup.csv](https://media.holistics.io/fad18386-sample-data-record-lookup.csv)
This template displays only the first row:
```csv
User ID,Email,Full Name,Profile Image URL,Location,Status,Member Since,Last Order Date,Total Orders,Lifetime Value,Avg Order Value
1005,chaisson.barb@thedoghousemail.com,Chaisson Barb,https://i.pravatar.cc/150?img=5,Berlin,VIP,Jun 2022,Nov 2024,45,6230,138
1002,ciubal.samuel@desilota.com,Ciubal Samuel,https://i.pravatar.cc/150?img=2,Mumbai,Active,Mar 2023,Oct 2024,18,2890,161
1004,homes.dale@ivebeenframed.com,Homes Dale,https://i.pravatar.cc/150?img=4,Medan,Active,Feb 2024,Oct 2024,12,1680,140
```
## Customization Tips
- Add more status badge styles by creating new CSS classes (e.g., `.status-badge.churned`)
- The status badge automatically applies styling based on the raw value matching a CSS class
- Use `rows.0` (dot notation) for single-record displays
- External links use `.raw` values to avoid formatting issues in URLs
---
## Getting Started with Dynamic Content Blocks
This tutorial walks you through creating your first Dynamic Content Block. By the end, you'll understand the basic workflow and be ready to build more complex templates.
## Prerequisites
- A Canvas Dashboard where you want to add the block
- A dataset with the fields you want to display
## Step-by-Step Tutorial
### Step 1: Add a Visualization Block
Start by adding a new Visualization block to your Canvas Dashboard, just like you would add any other chart.
### Step 2: Choose Markdown Visualization Type
In the visualization type selector, choose **Markdown** as your chart type. This enables the Dynamic Content Block editor with two tabs:
- **Chart Data**: Preview your data
- **Editor**: Write your template
### Step 3: Add Your Data Fields
Drag and drop the data fields you want to use in your template, just like building a Table or Pivot Table:
- Add dimensions (e.g., Product Name, Customer Name, Date)
- Add measures (e.g., Revenue, Order Count, Conversion Rate)
- Apply filters and sorting as needed
Use the **Chart Data** tab to preview your data and ensure it contains the information you need.
### Step 4: Build Your Template
Switch to the **Editor** tab to start building your template. You can use:
- **HTML**: For complete control over structure and styling
- **Markdown**: For simpler, text-based formatting
- **Mix both**: Combine HTML and Markdown as needed
### Step 5: Inject Your Data
Use template syntax to inject your data into the content. Here's a simple example:
```html
{{ rows[0].`Product Name` }}
Revenue: {{ rows[0].`Revenue` }}
```
The editor provides real-time preview so you can see how your template renders with actual data.
## Your First Template
Let's create a simple "Top Product" card. Assuming you have fields `Product Name` and `Revenue`:
```html
Top Product
{{ rows[0].`Product Name` }}
{{ rows[0].values.`Revenue` }}
```
## Next Steps
Now that you've created your first Dynamic Content Block:
- **[Syntax Reference](./syntax-reference.md)**: Learn all the template syntax options, including loops, raw values, and conditional styling
- **[Template Gallery](./gallery/)**: Browse ready-to-use examples for inspiration
---
## Dynamic Content Blocks
## Introduction
Dynamic Content Blocks let you build **custom, data-driven content** using Markdown, HTML, and CSS that **automatically updates with live data** from your models.
## Why Use Dynamic Content Blocks?
Standard chart types work well for common scenarios, but sometimes you need more flexibility:
- **Narratives with live data** - Generate insights like *"Revenue increased by 12.4% compared to Q2"* that refresh automatically, instead of static text
- **Custom visualizations** - Build Dumbbell charts, Kanban boards, calendar heatmaps, or any chart type not available in standard options
- **Branded layouts** - Design product catalogs, metric cards, or KPI displays that match your company's visual identity
- **Interactive controls** - Create custom filter buttons or date selectors using HTML and cross-filtering
## How It Works
Dynamic Content Blocks combine two things:
- Your **data** (from a dataset query)
- A **template** (HTML/Markdown with placeholders).
The template references data fields using a simple syntax, and Holistics renders the final content by injecting live values.
When your underlying data changes, the rendered content updates automatically.
## Limitations
- **No JavaScript execution** in templates (HTML/CSS only), for security reasons
## Get Started
- **[Getting Started](./getting-started.md)** - Step-by-step tutorial to create your first block
- **[Syntax Reference](./syntax-reference.md)** - Complete guide to template syntax and troubleshooting
- **[Template Gallery](./gallery/index.mdx)** - Ready-to-use examples you can copy and customize
---
## Template Syntax Reference
This page covers everything you need to know about writing templates for Dynamic Content Blocks, from basic data access to advanced techniques.
## Accessing Data Fields
### Basic Field Access
Reference your data fields using double curly braces with backticks for field names:
```aml
{{ rows[0].`Product Name` }}
{{ rows[0].`Revenue` }}
```
- `rows` is an array of all data rows returned by your query
- `rows[0]` accesses the first row, `rows[1]` the second, and so on
- You can also use dot notation: `rows.0` is equivalent to `rows[0]`
- Use backticks around field names, especially those with spaces
### Negative Indexing
Access rows from the end using negative indices:
```aml
{{ rows[-1].`Product Name` }} // Last row
{{ rows[-2].`Product Name` }} // Second to last row
```
### Raw and Formatted Values
Each field has two value types you can access:
```aml
// Get raw value (unformatted)
{{ rows[0].`Field Name`.raw }}
// Get formatted value (with formatting applied)
{{ rows[0].`Field Name`.formatted }}
```
**When to use `.raw`:**
- URLs for `` or ``
- Values for inline CSS styles (e.g., percentages for width)
- CSS class names derived from data values
- Values without link formatting
**When to use `.formatted`:**
- Display values with number formatting, currency symbols, etc.
- Dates with specific format applied
**Default behavior (no suffix):**
When you use ``{{ rows[0].`Field Name` }}`` without `.raw` or `.formatted`, it returns a clickable drill component with the formatted value as the label. This enables cross-filtering when users click on the value.
### Aggregated Values (Metrics)
Access aggregated metrics using the `values` accessor:
```aml
// Get all metric values for a row
{{ rows[0].values }}
// Get a specific metric value
{{ rows[0].values.`Total Revenue` }}
{{ rows[0].values.`Average Order Value` }}
```
## Looping Through Rows
### Basic Loop
Use the `map` function to iterate through all rows:
```aml
{% map(rows) %}
{{ `Product Name` }}: {{ `Revenue` }}
{% end %}
```
**Important**: Inside a `map` loop, reference fields directly without the `rows[0]` prefix.
### Accessing Values Inside Loops
Inside a loop, you can access field values and metrics directly:
```html
{% map(rows) %}
{{ `Product Name` }}
{{ values.`Revenue` }}
{% end %}
```
### Loop Example: Product List
```html
{% map(rows) %}
{{ `Product Name` }} - {{ values.`Revenue` }}
{% end %}
```
### Advanced Looping for Pivot Data
For pivot-style layouts (like heatmaps or matrices), you can loop through columns and cell values:
**`{% map(columns) %}`** - Iterate over column headers:
```html
{% map(columns) %}
{{ `Month Number` }}
{% end %}
```
**`{% map(values) %}`** - Inside a row loop, iterate over cell values:
```html
{% map(rows) %}
{{ `Cohort Month` }}
{% map(values) %}
{{ value.`Total Users` }}
{% end %}
{% end %}
```
**Complete example - Retention Heatmap:**
```html
Cohort
{% map(columns) %}
{{ `Month Number` }}
{% end %}
{% map(rows) %}
{{ `Cohort Month` }}
{% map(values) %}
{{ value.`Total Users` }}
{% end %}
{% end %}
```
Inside `{% map(values) %}`, use `` value.`Metric Name` `` to access specific metrics for each cell.
### Accessing Pivot Total Values
When working with pivot tables, you can access aggregated totals at three levels: per column, per row, and for the entire dataset (grand total). These are useful for rendering summary rows or footer totals in your template.
**Column Totals**
Use `col_totals` to access the total values for each pivot column. You specify the column value first, then the metric name:
```aml
// All column totals
{{ col_totals }}
// Total for a specific column
{{ col_totals.`cancelled`.`Revenue` }}
```
You can also iterate through all column totals:
```html
{% map(col_totals) %}
{{ value.`Revenue` }}
{% end %}
```
Note: `col_totals` is a key-value object, not an array, so you must use ``{{ value.`Field Name` }}`` inside the map.
**Row Totals**
Access the total across all columns for a specific row using `row_total` on that row:
```aml
{{ rows.0.`row_total`.`Revenue` }}
```
Inside a `map(rows)` loop, you can access the row total directly:
```html
{% map(rows) %}
{% map(values) %}
{{ value.`Revenue` }}
{% end %}
{{ `row_total`.`Revenue` }}
{% end %}
```
**Grand Totals**
Access the grand total for the entire dataset:
```aml
{{ grand_totals.`Revenue` }}
```
## Interactive Elements
### Clickable Element for Cross-filtering and Drill
To create clickable elements that filter other dashboard components, wrap them with the `` tag:
```html
{% map(rows) %}
{{ `Country Icon`.formatted }}
{{ `Country`.formatted }}
{% end %}
```
- The `` tag wraps the clickable element
- Use `.raw` for the `value` attribute (the actual filter value)
- Use `.formatted` for display text inside the element
### Styling Active State for Selected Data Point
When a user clicks on a data point or a drill element, the `.h-drill-selected` class is added directly to the child element inside ``. Use this class to style the active state:
```html
```
Note: The selector is `.h-drill-selected.clickable-card` (both classes on the same element), not `.h-drill-selected > .clickable-card`.
## Tips and Tricks
### Conditional Styling with CSS Classes
Apply CSS classes dynamically based on data values:
```html
{{ rows[0].`Status` }}
```
This works when your `Status` field contains values like "active" or "inactive" that match your CSS class names.
### Working with Images
Always use `.raw` for image URLs:
```html
```
Inside a loop:
```html
{% map(rows) %}
{% end %}
```
### Creating Links
Build dynamic links with data values:
```html
{{ rows[0].`Email` }}
View Details
{{ rows[0].`Location` }}
```
### Using Raw Values in CSS
When you need data values in inline styles (like positioning or sizing), you must use `.raw`:
```html
```
## Best Practices
1. **Start with your data**: Build and validate your dataset first using the Chart Data preview before creating your template
2. **Use the Chart Data tab**: Preview your data structure to understand field names and values
3. **Test with real data**: Use the live preview to see how your template renders with actual data
4. **Keep CSS scoped**: Avoid generic class names like `.title`, `.card`, `.button` that could conflict with Holistics app's styles or other dashboard blocks. Instead, use prefixed or specific names:
- ❌ Bad: `.title`, `.card`, `.button`, `.wrapper`, `.container`
- ✅ Good: `.custom-metric-title`, `.product-card`, `.mkt-insight-button`, `.dcb-wrapper`, `.deal-container`
5. **Handle missing data**: Test your template with incomplete data to ensure it fails gracefully
6. **Optimize performance**: For large datasets, consider limiting rows or using filters to reduce data volume
7. **Mobile responsive**: Use responsive CSS techniques if your dashboard will be viewed on mobile devices
## Troubleshooting
### Template not rendering
If your template isn't showing up:
- Check for syntax errors in your HTML/Markdown
- Ensure field names match exactly (case-sensitive) with backticks
- Verify your data query returns results in the Chart Data tab
### Data not displaying
If data fields show as empty:
- Confirm field names use backticks: `` `Field Name` ``
- Check if you need `.raw` for unformatted values
- Verify the row index exists (e.g., `rows[0]` requires at least one row)
### Styling conflicts
If your styles aren't applying:
- Use specific CSS class names to avoid conflicts
- Check browser developer tools for CSS specificity issues
- Ensure your `
The Capital Ledger
Est. 2024
Finance & Capital Markets
Wednesday, May 21, 2025
S&P 500 ▲ 5,304.72 +0.34%
DOW ▲ 39,112.16 +0.21%
NASDAQ ▼ 16,737.08 −0.18%
10Y UST 4.52%
GOLD ▲ $2,341.50
WTI ▼ $77.84 −0.9%
EUR/USD 1.0812
BTC ▲ $68,420
S&P 500
5,304
▲ +0.34%
VIX
13.42
▼ −0.8%
10Y Yield
4.52%
▲ +4 bps
DXY
104.71
▼ −0.12%
Markets
AAPL
189.40
▲ +1.2%
TSLA
174.82
▼ −2.4%
NVDA
882.15
▲ +3.1%
MSFT
418.70
▲ +0.7%
AMZN
185.44
▼ −0.3%
META
501.03
▲ +1.5%
Commodities
Gold holds above $2,340
Spot gold maintained gains as safe-haven demand persisted amid geopolitical uncertainty in the Middle East.
Oil slips on demand outlook
WTI crude slid 0.9% as IEA revised global demand forecasts downward for Q3, citing slowing Chinese industrial output.
Copper nears 2-year high
LME copper topped $9,800/t on supply tightness and renewed AI infrastructure buildout demand.
Lead Story · Federal Reserve
Fed Holds Rates, Signals Single Cut in 2024 as Inflation Proves Stubborn
Minutes from May meeting reveal a divided committee; two members dissented in favour of an immediate reduction
By Staff Correspondent
The Federal Open Market Committee voted 10–2 to hold the federal funds rate at 5.25–5.50 percent on Wednesday, marking the seventh consecutive meeting at which policymakers chose to leave borrowing costs unchanged. The decision, widely anticipated by futures markets, was accompanied by updated projections that reduced the committee's median expectation for rate cuts this year from three reductions to just one.
Chair Jerome Powell acknowledged that progress on inflation had "stalled somewhat" in the opening months of the year, with core PCE running above 3 percent for the fourth consecutive quarter. The committee would need "greater confidence" before reducing rates, he said.
Secondary Story · Treasury Markets
Yields Climb as Auction Draws Weak Demand; 10-Year Approaches 4.60%
Tuesday's $44 billion 10-year note auction saw the lowest bid-to-cover ratio in over a year, raising questions about appetite for long-duration paper as the fiscal deficit continues to widen. The 10-year yield rose 4 basis points to 4.52 percent.
Analysis
Opinion
The soft landing is real — but fragile
Labour markets remain resilient while inflation cools, but a single exogenous shock could snap the thread that keeps this cycle alive.
Earnings
Big Tech beats expectations for third straight quarter
Aggregate earnings for the Magnificent Seven rose 42% year-on-year, driven by AI cloud revenue and cost-cutting across headcount.
Private Equity
Buyout deal volume rises 18% as credit markets ease
Falling leveraged loan spreads and improving LP sentiment revived dealmaking, with Q1 global buyout volume reaching $98bn.
Crypto
Spot ETH ETF approval seen by July, analysts say
SEC's revised engagement with issuers raised approval odds to 70%, according to a Bloomberg Intelligence note published Wednesday.
Currency
Yen slides past 157, BoJ intervention risks rise
The yen tested fresh 34-year lows against the dollar as the Bank of Japan maintained its ultra-loose stance despite political pressure.
The Capital Ledger · All data delayed 15 minutes
For informational purposes only. Not financial advice.
© 2025 Capital Ledger Media
;;
}
theme {
block {
background { bg_color: "transparent" }
border { border_width: 0 }
}
}
}
```
### Paginated report
A two-page A4 annual report with a KPI strip, charts, and a data table. Each page uses fixed `210mm × 297mm` dimensions with `page-break-after: always` for clean PDF output.
Show full code
```aml
Dashboard html_annual_report {
title: 'Annual Report'
block title: TextBlock { ... }
block v_sroh: VizBlock { ... } // Revenue by quarter (column chart)
block v_gender_donut: VizBlock { ... } // Gender breakdown (donut)
block v_dd7r: VizBlock { ... } // Returned vs cancelled (area chart)
block v_5eey: VizBlock { ... } // Gross sales (KPI)
block v_active_clients: VizBlock { ... }
block v_gross_margin: VizBlock { ... }
block v_nps: VizBlock { ... }
block v_cpu: VizBlock { ... } // Cost per unit (area chart)
block v_churn: VizBlock { ... } // Churn rate (area chart)
block v_deal_vel: VizBlock { ... } // Deal velocity (bar chart)
view: HTMLLayout {
content: @html
Total Revenue
Active Clients
Gross Margin
NPS Score
Revenue Performance
Revenue by Quarter
Revenue Mix
By Gender
Returns vs Cancellations
By Quarter
Efficiency
Cost per Unit
Retention
Churn Rate
Pipeline
Deal Velocity
;;
}
theme {
block {
background {
bg_color: "transparent"
}
border {
border_width: 0
}
}
}
}
```
### Responsive bento grid
An asymmetric bento-style layout with three responsive breakpoints (desktop, tablet, mobile). The grid reflows automatically as the viewport narrows, with no JavaScript required — just CSS `grid-template-areas` and `@media` queries.
Show full code
```aml
Dashboard html_responsive {
title: 'Responsive Dashboard'
block title: TextBlock { ... }
block v_signup: VizBlock { ... } // Revenue by quarter (column chart)
block v_monthly: VizBlock { ... } // Monthly signups (column chart)
block v_yearly: VizBlock { ... } // Yearly signups (column chart)
block v_conversion: VizBlock { ... } // Net sales margin (KPI)
block v_metric_kpi: VizBlock { ... } // Gross sales (KPI)
view: HTMLLayout {
content: @html
bento
Responsive demo
DESKTOP >1100
TABLET 700-1100
MOBILE <700
Q2 2026 · Performance
Revenue is up +24% this quarter.
$4.2MTotal revenue
12,480Active customers
98.%Uptime
Monthly Signups
Net Sales Margin
Trend
Revenue this quarter
Collaboration
Active team
MR
JL
SK
AN
+8
12 members · 4 online now
Live feed
Recent activity
Maria closed deal #4821
2m
James updated forecast
8m
Sara published report
14m
Alex flagged risk
22m
Gross Sales
Total value of all products sold
Channels
Acquisition breakdown
;;
}
theme {
block {
background { bg_color: "transparent" }
border { border_width: 0 }
}
}
}
```
### Slides
A five-slide presentation deck with CSS-only navigation using anchor links and `:target`. Each slide shows a live chart alongside a headline and key insight, with prev/next buttons and dot indicators.
Show full code
```aml
Dashboard html_slides {
title: 'HTML Slides'
block title: TextBlock { ... }
block v_sroh: VizBlock { ... } // Users signup (column chart)
block v_7dri: VizBlock { ... } // Orders by status (donut)
block v_s3_area: VizBlock { ... } // Returned vs cancelled (area chart)
block v_s4_bar: VizBlock { ... } // Orders by status (bar chart)
block v_s5_kpi: VizBlock { ... } // Gross sales trend (area chart)
view: HTMLLayout {
content: @html
DASH·HQ
Deck: Q2 Insights
FY 2026 · INTERNAL
← Prev
01 / 05
Next →
Q2 2026 · Top-line Finding
Revenue grewfaster than forecast.
We exceeded plan by over 18% this quarter, driven by enterprise expansion and faster time-to-value in onboarding.
+18.4%
YoY revenue growth, vs +12% Q1 forecast
← Prev
02 / 05
Next →
Channel Attribution
Enterprise carriedthe quarter.
Enterprise contracts accounted for 43.8% of new revenue, up from 31% a year ago. Mid-market held steady.
43.8%
share of new revenue from enterprise segment
← Prev
03 / 05
Next →
Customer Health
Retention hit anew high.
Gross retention reached 97.9% — the lowest churn in eighteen months. Expansion revenue from existing customers grew 31% YoY.
97.9%
gross revenue retention, ▲ +1.4pp vs Q1
← Prev
04 / 05
Next →
Sales Velocity
Cycle timecollapsed.
Average sales cycle dropped from 53 days to 42 days. The new onboarding playbook shaved meaningful time off every stage of the funnel.
−11d
reduction in average sales cycle vs Q1
← Prev
05 / 05
↺ Restart
Voice of Customer
NPS climbed toan all-time high.
Net Promoter Score reached 64, up from 51 last quarter. Customer feedback highlighted faster onboarding and product reliability as the biggest drivers.
64
NPS score, ▲ +13 vs Q1 — record high
;;
}
theme {
block {
background { bg_color: "transparent" }
border { border_width: 0 }
}
}
}
```
### Tabbed dashboard
A four-tab dark analytics dashboard with CSS-only tab navigation using anchor links and `:target`. Each tab reveals a different panel (Overview, Performance, Breakdown, Activity) with KPI cards and live charts, all wired to the same shared blocks.
Show full code
```aml
Dashboard html_tabbed_report {
title: 'Tabbed Report'
block v_tab_trend: VizBlock { ... } // User signups by quarter (column chart)
block v_tab_donut: VizBlock { ... } // Orders by status (donut)
block v_tab_bar: VizBlock { ... } // Orders by status stacked (bar chart)
block v_tab_area: VizBlock { ... } // Returned vs Net Sales (area chart)
block v_tab_margin: VizBlock { ... } // Net Sales Margin trend (area chart)
block v_tab_acquisition: VizBlock { ... } // User acquisition by month (column chart)
block v_kpi_rev: VizBlock { ... } // Gross Sales (KPI)
block v_kpi_users: VizBlock { ... } // Total Users (KPI)
block v_kpi_margin: VizBlock { ... } // Net Sales Margin (KPI)
block v_kpi_return: VizBlock { ... } // Return Ratio (KPI)
block filter_date: FilterBlock { ... } // Order Date filter
view: HTMLLayout {
content: @html
DASH·HQ
Workspace:Acme Corp
Last sync: 2 min ago
LIVE
OVERVIEW
FY 2025 · ALL SEGMENTS
📅
Gross Sales
Total Users
Net Sales Margin
Return Ratio
Revenue Performance
MONTHLY TREND
Export ↗
Revenue
Target
Prior Year
Mix
REVENUE BY SEGMENT
Details →
Enterprise
43.8%
Mid-Market
31.2%
SMB
25.0%
Funnel
PIPELINE
Churn
RETENTION CURVE
Sentiment
NPS TREND
BREAKDOWN
USER COHORTS · ACTIVITY
📅 Last 30 days
DAU
5,210
▲ +6.4% vs last week
sparkline
WAU
14,830
▲ +3.9% vs last week
sparkline
MAU
42,016
▲ +5.1% vs last month
sparkline
Avg. Session
4m 12s
▼ −14s vs last week
sparkline
Growth
USER ACQUISITION
Filter ↗
Mix
DEVICE SPLIT
ACTIVITY
REPORTS · AUDIT LOG
📅 Last 7 days
Generated
RECENT REPORTS
New report ↗
Report
Type
Generated
Status
Q4 Revenue SummaryFinancial2 hours agoReady
User Acquisition ReportMarketing1 day agoReady
Churn AnalysisRetention3 days agoReady
Product PerformanceOperational1 week agoArchived
Volume
REPORT TIMELINE
Summary
Performance
Breakdown
Activity
;;
}
theme {
block {
background { bg_color: "transparent" }
border { border_width: 0 }
}
}
}
```
## Limitations
**No JavaScript support:** JavaScript is not supported inside HTML Layout. As an alternative, you can use CSS to cover most interactive patterns - the examples above use `:target` for tabs and slides, `@media` for responsive layouts, and CSS animations for entrance effects.
## Syntax reference
For the full syntax reference:
- See the [AML HTMLLayout](/reference/aml/html-layout) for `HTMLLayout` and ``.
- See the [AML Dashboard](/reference/aml/dashboard) for dashboard in general.
---
## Canvas Dashboard
:::info
Holistics offers two dashboard types:
- **Canvas dashboard**: Our latest, most innovative dashboard. We highly recommend using canvas dashboards for the best experience and advanced customization.
- [**Quick dashboard**](/docs/dashboards/quick-dashboards): Now a legacy version. This version has been deprecated for all new accounts created since **February 10, 2026**. Existing accounts can disable it via the Administration settings. [Learn more](/docs/dashboards/quick-dashboards#how-to-deprecate-the-quick-dashboard).
For more details on the differences between these two dashboard types, see [this comparison section](/docs/dashboards/quick-dashboards#compare-canvas-dashboard-and-quick-dashboard).
:::
## Introduction
**Canvas dashboard** gives you complete design freedom to create data presentations that match your exact requirements. Build executive dashboards, analytical reports, and operational monitors with full control over how your data is presented and organized.
Key features of Holistics canvas dashboards:
- **Flexible layout control**: Position visualizations, filters, and text elements exactly where you need them without grid constraints. Arrange your dashboard to support your narrative and meet your specific design requirements.
- **[Definition as code](#dashboard-as-code)**: Define your dashboard as code in AMQL for version control through Git, reusable components, and bulk editing at scale.
- **Separate development environment**: Build and refine your dashboards in a dedicated Development workspace before deploying to production. This ensures end-users only see completed, ready-to-use dashboards while you work on improvements.
## Dashboard components
Canvas dashboards combine two key concepts: **[Block](#block)**, and **[Layout](#layout)**.
### Block
A canvas dashboard is composed of modular units called **analytics blocks**. Think of it like constructing a LEGO castle from individual bricks. each block serves a specific purpose and can be combined in countless ways.
Holistics provides a wide range of blocks:
- **[Text block](/docs/canvas-dashboard/text-block):** To incorporate context and information into your dashboards. They support Markdown, images, and embedded videos.
- **[Visualization block](/docs/visualizations/):** To add a variety of visualizations to your dashboards, including custom charts.
- **Control block:** Such as [Filter](/docs/filters/), [Period Comparison](/docs/period-comparison), [Date Drill](/docs/interactions/date-drills), allowing you to manipulate data and add interactivity to your dashboard.
- **[Reused block](/docs/reused-blocks):** Let you define a single “main block” and reference it in many dashboards. That way, you can build it once and reuse it multiple times, while having the flexibility to customize where needed.
### Layout
Blocks are positioned and organized by a **layout**. Holistics supports three layout types:
- **Canvas layout**: A single free-form page where blocks are positioned with pixel coordinates; this is the default for new canvas dashboards.
- **[Tab layout](/docs/dashboards/tabs.md):** Functionally similar to canvas layout, but content is split into tabbed sections.
- **[HTML layout](/docs/dashboards/html-layout):** Write HTML and CSS directly to structure the dashboard, with blocks placed using the `` web component. Best for use cases Canvas and Tab layouts handle poorly - complex or creative compositions, pixel-perfect reports, and paginated output.
## Dashboard as code
When you build a canvas dashboard, Holistics automatically generates [its underlying code](/as-code/amql/). This approach allows you to apply engineering best practices to your dashboard development.
This means you can:
- Govern your dashboards with [Git version control](/docs/git-version-control/)
- Refactor your dashboards at scale
- Define [reusable functions](/as-code/aml/reusability-overview)
- Perform [code reviews](/docs/continuous-integration/pr-workflow-auto-deploy)
And much more.
## How to create a canvas dashboard
:::info
Canvas dashboards in [Public workspace](/docs/admin/permission-system#public-workspace) are accessible in both **Development** and **Reporting** environments.
[Personal canvas dashboards](/docs/admin/permission-system#personal-workspace) are only accessible in **Reporting**. Support for personal canvas dashboards in Development is under consideration.
:::
### Create a dashboard from scratch
You can create a new dashboard in both Development and Reporting environments:
- **Development (for admins / analysts):** This environment is designed for creating, modifying, and reviewing dashboard changes with full version control. In addition, you’ll get access to both a **code editor** and a **visual editor**, allowing you to work directly with our [Analytics Modeling Language (AML)](/as-code/amql/) or use a drag-and-drop interface.
Learn more in [Developing in Development](/docs/canvas-dashboard/create-edit-canvas-dashboard/in-development).
- **Reporting (for all authorized users, except for viewers):** This environment is ideal for exploring data and making minor changes if you have the necessary permissions. It offers only the **visual editor**, which means you'll have limited access to the more powerful “as-code” functionalities.
Learn more in [Developing in Reporting](/docs/canvas-dashboard/create-edit-canvas-dashboard/in-reporting).
### Save explore result to a dashboard
When you find something meaningful when [exploring a dataset](/docs/data-exploration), you can save your results. Holistics allows you to save exploration results directly to an existing dashboard or to a new one.
- **Development**: Can only save explore results to canvas dashboards.
- **Reporting**: Can save explore results to both [quick dashboards](/docs/dashboards/quick-dashboards) and canvas dashboards.
## FAQs
#### 1. How can we get access to the canvas dashboard?
Canvas dashboard is available exclusively on **Holistics version 4.0**. Please ensure you are using this version. If not, you can [migrate your Holistics instance to version 4.0](/as-code/3.0-to-4.0-migration).
#### 2. What happens to my existing dashboards if I migrate from Holistics version 3.0 to 4.0?
Your existing dashboards will remain **fully functional and unchanged** after the migration.
Holistics 4.0 is fully backward compatible for your account, so you can continue to use both quick dashboards (your current version) and the new canvas dashboards side-by-side. Everything will continue to work exactly as expected.
**Our recommendation:** While your existing dashboards are supported, we strongly recommend converting them to canvas dashboards. As our most modern and innovative version, canvas dashboard offers the best overall experience and advanced capabilities, whereas quick dashboard is now maintained as a legacy version.
#### 3. I'm using both canvas dashboards and quick dashboards. Is the older version going to be deprecated, and when?
Quick dashboards have already been deprecated for all new accounts created after *February 10, 2026*. For existing accounts, *there is no forced deprecation timeline*. your quick dashboards will continue to work as normal.
However, if you'd like to simplify your workspace and go canvas-only, you can disable it via the Administration settings. [Learn more](/docs/dashboards/quick-dashboards#how-to-deprecate-the-quick-dashboard).
**Behavior after deprecation:**
- Can view, edit, and delete existing quick dashboards
- Can't create new quick dashboard
#### 4. How to generate a canvas dashboard from a quick dashboard?
**Prerequisites:**
To convert a quick dashboard to a canvas dashboard, ensure the following:
- You are on Holistics version 4.0.
- You have an **admin**, **analyst**, or **explorer** role.
- You have permissions for the underlying datasets of all blocks included in the dashboard.
**How to generate**:
- Click the :arrow_up: button on your quick dashboard.
- Choose the saving folder in:
- [**Public workspace**](/docs/admin/permission-system#public-workspace): You can only choose folders where you have **create & edit child** or **edit** permission.
- [**Personal workspace**](/docs/admin/permission-system#personal-workspace): You can save to any folder.
:::note
- This process **doesn't affect your original quick dashboard**. You'll have two separate dashboards: the original quick dashboard and the newly created canvas dashboard.
- The new canvas dashboard might **not perfectly match the visual style** of your old dashboard due to design differences. We recommend reviewing the preview version first.
:::
---
## Period-over-Period Controls
Period-over-Period Controls are dashboard-level controls that let viewers compare data across different time periods (this month vs last month, this year vs last year) across multiple visualization blocks at once. Viewers can quickly switch between different comparison periods without editing individual visualization blocks.
When you add a Period-over-Period Control to your dashboard, you can:
- **Map it to multiple visualization blocks** - Apply the same period comparison to several visualization blocks at once, keeping comparisons synchronized
- **Control which visualization blocks respond** - Selectively choose which visualization blocks should respond to the period comparison control
- **Persist the view in exports** - The selected comparison carries over to exported files and scheduled reports, so stakeholders see data with the right context
## How to Set Up Period-over-Period Controls
Follow these steps to add a Period-over-Period Control to your dashboard:
1. **On the Dashboard**: Click the "Add Control" button in the dashboard toolbar, then select "Period Comparison" from the dropdown menu
2. **Configure the Period Comparison**:
- **Select comparison type** - Choose the comparison pattern (e.g., Previous Period, Same Period Last Year)
- **Select period to compare** - Choose the time unit for comparison (e.g., Month, Quarter, Year)
- **Select visualization blocks to map** - Enable the checkboxes for visualization blocks you want this control to affect
- **Choose date fields** - For each enabled visualization block, select which date/time field acts as the time dimension for the comparison
3. **Save the control** - The Period-over-Period Control now appears on your dashboard, and viewers can use it to compare data across different time periods for all mapped visualization blocks
## Learn More
To understand different comparison types and how period-over-period calculations work, see [Period Comparison](/docs/period-comparison).
---
## Quick Dashboard
:::warning
This is a legacy dashboard. For all new dashboards, use **[Canvas dashboard](/docs/dashboards/)**.
:::
:::info
Holistics offers two dashboard types:
- **[Canvas dashboard](/docs/dashboards/)**: Our latest, most innovative dashboard. We highly recommend using canvas dashboards for the best experience and advanced customization.
- **Quick dashboard**: Now a legacy version. This version has been deprecated for all new accounts created since **February 10, 2026**. Existing accounts can disable it via the Administration settings. [Learn more](#how-to-deprecate-the-quick-dashboard).
For more details on the differences between these two dashboard types, see [this comparison section](/docs/dashboards/quick-dashboards#compare-canvas-dashboard-and-quick-dashboard).
:::
## Introduction
Quick dashboard is a **collection of widgets** that help you tell a story or paint a big picture of your organization. This is where you save your ad-hoc data exploration into something permanent and deliver continuous value to your end users.
We are still maintaining quick dashboards for tenants using Holistics 3.0, but plan to deprecate it. Learn more [**here**](/docs/dashboards/#3-im-using-both-canvas-dashboards-and-quick-dashboards-is-the-older-version-going-to-be-deprecated-and-when).
## Quick dashboard editor
1. **Main editor:** The main area to build the dashboard and apply interactive controls.
2. **Settings & deliver data:** Edit dashboard time zones, cache, other preferences, and deliver dashboard with export and shareable link.
3. **Generate canvas dashboard:** Create another version in canvas dashboard. You will now have two dashboards: the original quick dashboard and the new canvas dashboard.
## Quick dashboard components
A Quick Dashboard is composed of [**Widgets**](/docs/dashboards/quick-dashboards#widgets) and [**Filters**](/docs/dashboards/quick-dashboards#filters).
### Widgets
There are two types of widgets: **Report widget** and **Text widget**.
- **Report widgets** are used to visualize your data. Since Report widgets are backed by datasets, you can freely explore the underlying data. For more information on the types of reports, you can refer to [Visualization](/docs/visualizations/).
- **Text widgets** are used to provide additional instructions, context, and warnings directly in your dashboard. The widget supports **Markdown**, which should serve your most common formatting requirements. Learn more with [Text, Markdown, HTML](/docs/canvas-dashboard/text-block).
### Filters
[Filters](/docs/filters/) allow viewers to limit results to specific data of interest. When interacting with the dashboard filter (selecting operators, filter values...), a relevant `WHERE` condition applies to widgets using it.
## How to create a quick dashboard
### Create a dashboard
- **Create from scratch**: You can create a new Quick Dashboard from several places in Reporting: From top navigation bar, from left sidebar, or inside a folder.
- **Save explore result to a dashboard:** When you find something meaningful when [exploring a dataset](https://docs.holistics.io/docs/data-exploration), you can save your results. Holistics allows you to save exploration results directly to an existing dashboard or to a new one.
### Add widgets and filters
After creating a dashboard, you can start adding widgets and filters directly from the dashboard page.
## Compare Canvas dashboard and Quick dashboard
Canvas Dashboard (Recommended)
Quick Dashboard
Definition
A flexible canvas that generates code underneath the hood for analysts to easily maintain and scale your dashboard.
A simple, built for solo analysis, suitable when you need an ad-hoc report.
Development
Layout
Freely arrange visualizations, texts, filters, etc. anywhere on Canvas
Visualizations, and texts are arranged onto fixed-size grids. Filters and dashboard controls are always on top
As-code definition
Declarative AML as-code definition
Not supported
Reusability
Inherit AML's reusability
Not supported
Version control
Version control with Git integration
Not supported
Development experience
Develop dashboard in Modeling layer. Changes are applied after publishing to production
Develop dashboard in Reporting layer. Changes are automatically applied
View & Interaction
Responsiveness & Scaling
Keep the dashboard ratio. Allow users to zoom in/out the dashboard to fit their screen. Control the size of canvas in pixels
Auto-resize the dashboard to fit the users' screen. Dashboards look differently on different screens
Filters, Date Drills, Period Comparison
Place anywhere within dashboard. Support easy reset to default values
Always stay on top of dashboard. Manually reset to default values
Cross-filtering
Support ability to selectively apply/exclude cross-filtering to a subset of visualizations. Cross-filtering triggers when clicking on Apply controls
When enabled, cross-filtering is applied to the whole dashboard (among visualizations using the same datasets). Cross-filtering automatically triggers on click
Drill-through
Support Drill-through between multiple Canvas Dashboards
Support Drill-through between multiple Quick Dashboards
Export
Export dashboard: Support export as PNG/PDF
Export visualizations: Support export as PDF/Excel/CSV
Data Schedules
Support send to Email / Slack / Google Sheets / SFTP
Data Alerts
Support send alerts to Email / Slack / Webhook
Embedded Analytics
Support create Embed Link in Dashboard Preferences
Explore data from dashboards
Admin, Analyst, and Explorer can explore a visualization in a dashboard, but cannot save the exploration results
Admin, Analyst, and Explorer can explore a visualization in a dashboard, and save the exploration results to another dashboard
Private dashboards
Not supported
Admin, Analyst, and Explorer can create new dashboards in private workspace.
## FAQ
### How to deprecate the quick dashboard
**Behavior after deprecation:**
- Can view, edit, and delete existing quick dashboards
- Can't create new quick dashboard
**How to deprecate:**
- Access the **Administration settings > Settings > Legacy features**
- Choose to disable the quick dashboard
---
## Canvas Settings
# Canvas dashboard settings
This doc covers settings for [Canvas dashboards](/docs/dashboards/) in two categories:
- **[Layout settings](#layout-settings)** help you design and arrange components while building your dashboard. These settings are unique to canvas-based dashboards.
- **[General dashboard settings](#general-dashboard-settings)** control how your dashboard runs and displays for end users.
## Layout settings
### Grid settings
Enable **Snap to grid** to automatically align blocks to an invisible grid as you position them. This helps you create neat, consistently-spaced layouts.
When snap to grid is on, you can set the **Grid size** to control how fine or coarse the alignment points are. Smaller grid sizes give you more precision, while larger sizes enforce more spacing between elements.

### Layout assist
This setting includes smart features that make it faster and easier to arrange dashboard components:
- **Prioritize non-overlap**: Blocks automatically avoid overlapping as you drag them around. To manually layer blocks, press and hold one for 2 seconds.
- **Smart trimming**: Detects excess whitespace in your dashboard and removes it with one click.
- **Smart shifting**: Move multiple blocks together at once instead of repositioning them individually.
- **Auto-expand canvas**: The canvas grows automatically when you add blocks near the edge, so you never run out of space.
### Zoom settings
Adjust your canvas dashboard view while editing with flexible zoom controls:
- **Zoom in/out**: Adjust your dashboard view incrementally
- **Zoom to 100%**: Return to actual size of the dashboard
- **Fit to width**: Scale dashboard to match your screen width
- **Fit to page**: Fit the entire dashboard in your viewport
You can also enter a custom zoom percentage directly in the zoom field.

#### Set a default zoom level
To make a canvas dashboard always open at a specific zoom level:
1. In edit mode, adjust zoom to your preferred view
2. Click **Make current zoom default** and save your changes
## General dashboard settings
:::info
These settings are only available in Reporting environment: [User access](#user-access), [Data schedules](#data-schedules), [Shareable links](#shareable-links), [Embedded analytics](#embedded-analytics).
:::
### Dashboard auto-run
Two settings control when the dashboard executes queries. Both are configured in **Dashboard Preferences** > **General**.
:::info
These settings do not apply to [Shareable links](/docs/delivery/shareable-links), [Embed links](/embedded/single-dashboard/), and dashboard URLs with pre-applied filters.
:::
#### Auto-run on open
By default, dashboards run all widgets automatically when opened. You can turn this off to give viewers control over when data loads.
When disabled, the dashboard stays blank until the viewer manually triggers it:
- Click **Run Dashboard** to load all widgets
- Apply a filter to run dependent widgets
- Click **Refresh** to reload the dashboard
**Benefits of disabling:**
- Give viewers control over when to load data, reducing the initial wait time
- Minimize unnecessary queries to your data warehouse
- Free up [Job Workers](/docs/jobs/queues-and-workers) for higher-priority tasks
**How to configure:**
1. Go to **Dashboard Preferences** > **General**
2. Toggle **Auto-run on open** and save your changes
3. Apply the change:
- In **Development** mode: Publish your changes to Production
- In **Reporting** mode: Reload the page
#### Auto-run on changes
When enabled, the dashboard re-runs automatically whenever a filter, control, or source visualization changes - no **Apply** button needed.
This setting is off by default to avoid unexpected query costs on dashboards with many charts or intensive queries. Builders opt in for dashboards where immediate feedback matters and query cost is acceptable.
**When to use:** Simple dashboards or dashboards used by non-technical viewers who expect data to update immediately after changing a filter.
**How to configure:**
1. Go to **Dashboard Preferences** > **General**
2. Toggle **Auto-run on changes** and save your changes
3. Apply the change:
- In **Development** mode: Publish your changes to Production
- In **Reporting** mode: Reload the page
### Cache settings
Control how long dashboard query results are stored before refreshing. Caching speeds up load times and reduces data warehouse load by reusing recent results. This is useful when your data updates infrequently or when many users access the same dashboard.
Learn more: [Data caching](/docs/performance/data-caching.md)
### Timezone settings
Set the timezone for how dates and times display in your dashboard. This ensures all users see data in the correct time context, which is especially important for time-sensitive metrics or teams across different regions.
Learn more: [Dashboard timezone](/docs/datetimes/timezones#2-dashboard-timezone)
### User access
Control who can view and edit your dashboard. Learn more: [Dashboard-level permissions](/docs/admin/permission-system#dashboard-level-permission)
### Data schedules
Set up automated email deliveries of your dashboard on a recurring schedule. Learn more: [Data schedules](/docs/delivery/external)
### Shareable links
Create public or password-protected links to share your dashboard with people outside your workspace. Shareable links let you distribute dashboards without requiring recipients to have a Holistics account, making it easy to share insights with clients, partners, or stakeholders.
Learn more: [Shareable links](/docs/delivery/shareable-links)
### Embedded analytics
Configure settings for embedding your dashboard in external applications, websites, or internal tools. This allows you to integrate Holistics dashboards into your product or portal while maintaining security and branding.
Learn more: [Embedded analytics](/embedded/)
---
## Tabs
## Introduction
VIDEO
Standard dashboards are great for single views, but heavy layouts can become cluttered and slow. **Tabs** free you from single-page constraints, allowing you to combine multiple related dashboards into a single, organized interface.
Choose **tabs** when traditional canvas layout can't support your use case:
- **Unified dashboards**: Group connected views that belong together, such as high-level summaries alongside granular breakdowns.
- **Audience-specific views**: Segment metrics for different teams (e.g., separate tabs for Marketing, Sales, and Support) within a single asset.
- **Performance optimization**: Break down massive, slow-loading dashboards into lightweight, on-demand tabs that load only when clicked.
## High-level understanding
### Tab concept
Conceptually, **Tabs** provide a distinct dashboard layout option (called `Tablayout`), allowing you to organize your analytics blocks within a tabbed structure.
:::info Important
Visually, a dashboard built with a **TabLayout** and containing only one tab will appear similar to a **CanvasLayout** dashboard. The fundamental difference lies in their underlying syntax and design purpose:
- **CanvasLayout**: Built for single-page dashboards, ideal when all content fits on one screen.
- **TabLayout:** Built for managing and displaying multiple pages *of content*, even if you start with just one tab.
:::
### Interactions in a tabbed dashboard
Since it is a layout option, **all analytics blocks within a tabbed dashboard remain connected**. This enables powerful cross-tab interactions, such as mapping a filter in Tab A to a visualization in Tab B.
By default, interactions (like filters) are automatically enabled only between blocks *within the same tab* to optimize initial user experience and performance. For scenarios requiring broader control, you can easily **manually enable cross-tab interactions** to link elements across different tabs.
### Performance in a tabbed dashboard
Using Tabs helps optimize performance, especially for large and complex dashboards. Here's how Tabs are designed to improve performance:
- **On-Demand Loading:** Only the tabs that users actually open are loaded (no preloading), which reduces database queries and helps you save on query runs that count toward your subscription usage.
- **Smart Caching:** Tabs prioritize cached data from both front-end and back-end, only running fresh queries when cached data isn't available to reduce load times and query usage.
## How-to
### Create tabs
You can create a new tab either:
- **Via GUI:** Look for the "New Tab" button (or Plus (+) icon button at the top pane of your dashboard, next to the dashboard title.
- **Via code:** In your code editor, add `TabLayout {}` to define the dashboard view as a tab layout. Note that editing a dashboard via code is only available in [Development](https://docs.holistics.io/docs/development/aml-studio). See more details about [Syntax](/docs/dashboards/tabs#syntax) here.
### Customize tabs styling (to be supported)
Currently, tabs use a default styling that appears consistently across all dashboards, regardless of the selected Theme.
### Mobile experience
Tabs are fully responsive on mobile devices. You can customize the mobile display settings for each tab individually.
For detailed mobile optimization guidance, see our [Mobile Responsive Canvas Dashboard](https://docs.holistics.io/docs/canvas-dashboard/mobile-responsive) documentation.
## Sync block
**Sync block** is a unique and powerful feature exclusive to tabbed dashboards. It allows you to maintain consistent content across multiple tabs.
When you configure a block as a sync block, updating one instance automatically reflects the change across all other linked tabs. This is ideal for common elements like date filters, key metrics, or standard headers that must remain uniform throughout your tabbed dashboard.
### How-to
When copying and pasting any block between tabs using `Ctrl/Cmd + C` and `Ctrl/Cmd + V` you can choose between two options: **Paste as copy**, and **Paste and sync.**
**Paste as Copy:**
- Creates an independent copy of the original block.
- Changes to this copy won't affect the original block.
**Paste and Sync:**
- Creates a synchronized copy of the original block.
- Any changes to the original block or its copies will sync across all tabs.
## Syntax reference
For the full syntax reference, see [AML Tab Layout](/reference/aml/tab-layout).
## FAQs
1. **Can I convert my existing normal dashboard to tabbed dashboard?**
Yes, you can easily convert any existing dashboard to use tabs. Simply click the "New Tab" button at the top of your dashboard, or add `TabLayout {}` in the code editor. Your existing content will become the first tab. See [Create tabs](/docs/dashboards/tabs#create-tabs) for detailed instructions.
2. **Is there a limit to the number of tabs I can add to a dashboard?**
There is no hard limit on the number of tabs you can create per dashboard.
However, for optimal performance and user experience, we recommend keeping dashboard to **under 10 tabs** and **no more than 100 blocks** in total.
3. **What happens to synced blocks if I delete the original tab?**
Synced blocks don't have an "original" - all synced instances are equal. When you delete a tab containing synced blocks, only the blocks in that tab are removed. The remaining synced blocks in other tabs continue to work and sync normally.
---
## Visualization Blocks
Visualization blocks (charts, tables) are key components that help you tell the story on your dashboards. They're the primary way to present data findings and enable interactive exploration.
## Creating Visualization Blocks
You have three ways to create visualization blocks:

**Create Directly in Dashboard**: Build visualization blocks directly on your dashboard using the canvas tool panel.
**[Save from Dataset exploration](/docs/data-exploration)**: Explore your dataset first, build your chart, then save it to a dashboard.
**[Save from AI conversation](/docs/ai/capabilities)**: Ask questions about your data using the AI chat, generate visualizations, then save them to your dashboard.
## Using Visualization Blocks
### Present Data and Insights
Use visualization blocks to:
- **Highlight key metrics** with KPI cards and scorecards
- **Show trends over time** with line and area charts
- **Compare categories** with bar and column charts
- **Display distributions** with pie charts and histograms
- **Reveal relationships** with scatter plots and bubble charts
Choose from a wide variety of [chart types](/docs/visualizations) to match your data and message.
### Follow Up with AI
Users can click on any visualization block to ask questions and get AI-powered insights about the data they're viewing. The AI understands the context of your chart and can help uncover patterns, explain anomalies, or suggest next steps.
### Enable Interactive Exploration
Visualization blocks aren't static - users can interact with them to dig deeper:
- **Break down data** by adding new dimensions on the fly
- **Drill down** into specific data points to see underlying details
- **Filter across the dashboard** by clicking on data points (cross-filtering)
- **Drill through** to related dashboards for deeper analysis
- **View underlying data** to see the raw records behind any chart
Learn more about [Dashboard Interactions](/docs/interactions/interact-with-canvas-dashboard).
## Related Topics
- [Data Exploration](/docs/data-exploration)
- [Chart Types](/docs/visualizations)
- [Canvas Dashboards](/docs/dashboards)
- [Dashboard Interactions](/docs/interactions/interact-with-canvas-dashboard)
---
## Condition Expressions
Condition Expressions let you apply advanced filters for your explore with complex logic leveraging AQL.
## When to use Condition Expressions
- **Apply nested filtering** - Filter based on the results of another calculation (e.g., find customers who made their first purchase in a specific category, then analyze their subsequent behavior). [Learn more.](/reference/aql/aql-condition#example-1-nested-filtering)
- **Filter across multiple models** - Apply conditions to fields from different data models simultaneously (e.g., filter both buyer and seller attributes in a transaction dataset). [Learn more.](/reference/aql/aql-condition#example-2-filter-multiple-fields-from-different-models)
- **Build complex AND/OR logic** - Combine multiple conditions with advanced logic that goes beyond simple filters. [See example.](#example)
## How to add a Condition Expression
In data exploration interface, in condition, choose **Add custom expression** to add your custom filter expression using [AQL syntax](/reference/aql/aql-condition).

### Example
Here's a simple example filtering for female users or users from Vietnam:
```ts
users.gender == 'female' or countries.name == 'Vietnam'
```
## Learn more
For detailed syntax and in-depth examples including **Nested Filtering** and **Filtering multiple fields from different models**, see [AQL Condition Reference](/reference/aql/aql-condition).
---
## Using SQL for Data Exploration
:::info
SQL Editor is only available to **analysts** role.
:::
Beside the [Dataset](/docs/datasets), Holistics also has the **SQL Editor** as another way to explore your data. This tool is suitable for running simple SQL queries to do ad-hoc analysis, to understand the shape of your data, or to prototype a data model setup.
Similar to the Dataset UI, it also has the visualization panel in case you want to visualize your query results:
The editor can be accessed from the top Navigation Bar:
## When to Use SQL
SQL queries are useful when you need to:
- Perform complex calculations not easily expressed through the visual interface
- Join data from multiple sources in custom ways
- Test and prototype data transformations before modeling them
- Run ad-hoc analysis that doesn't fit standard patterns
## Creating SQL Reports
You can create reports directly from SQL queries in Holistics. This allows you to leverage your SQL knowledge while still benefiting from Holistics' visualization and sharing features.
### Basic Steps
1. Navigate to the data exploration interface
2. Choose the SQL query option
3. Write your SQL query
4. Preview the results
5. Choose a visualization type
6. Configure the chart settings
## SQL vs Models
While SQL provides flexibility, we recommend using [Data Models](/docs/data-model) for most use cases because they:
- Are reusable across multiple reports and dashboards
- Provide consistent business logic and definitions
- Enable non-technical users to explore data
- Support advanced features like relationships and metrics
- Are easier to maintain and version control
## Best Practices
- **Use Models when possible**: Build reusable models instead of writing SQL for every report
- **Document your SQL**: Add comments to explain complex logic
- **Test performance**: Check query execution time before adding to dashboards
- **Validate results**: Ensure your SQL produces accurate results
## Running Non-Select SQL Queries
By default, Holistics only allows SELECT statements to run against your database. However, you can still run non-SELECT statements (like INSERT, UPDATE, DROP, CREATE, GRANT...) in the SQL Editor by toggling on **Non-select query** option:
If this is not toggled on, the non-SELECT query will be invalidated by Holistics.
This functionality is available to all account types that has access to Query Editor (i.e Admin & Analyst).
:::caution
Non-select statements may affect your database directly and cause irreversible changes. Please take great care when using this functionality.
:::
## Limitations
SQL queries in Holistics may have certain limitations:
- Cannot be used as a source for other models
- May not support all database-specific features
- Performance depends on your database connection
## Related Topics
- [Create Reports from SQL](/faqs/create-reports-from-sql)
- [Data Models](/docs/data-model)
- [Query Models](/docs/query-models)
---
## Explore Data
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dataset](datasets)
:::
## Explore with datasets
Exploring data from a dataset is the starting point toward building a dashboard. Using datasets, users can slice and dice your data with a familiar drag-and-drop interface.
Behind the scenes, Holistics generates queries from user's interactions, and then runs them against the database. When the result set is returned from the database, Holistics combines it with the visualization settings to render the result to your browser.
In the following sections, we will walk through some basic concepts to get started with data exploration.
### How it works
You select data from the Dataset fields Panel and use the Visualization settings Panel to configure your final visualization.
### Dataset fields Panel
When opening a dataset, user is presented with a list of fields:
- **Dimensions** are non-aggregated data fields displayed in black. Dimensions can be of any data type.
- **Measures** are aggregated data fields (counting, summing, averaging, etc.). Measures are displayed in blue along with a sigma icon.
### Visualization settings Panel
#### Chart Types and Styles
Choose from a wide range of chart types to best represent your data, including tables, line charts, bar charts, pie charts, and more specialized visualizations.
See the [Visualizations](/docs/visualizations) section for detailed documentation on each chart type.
#### Data Fields
Configure which dataset fields are used in the visualization. The required fields change depending on the selected visualization type. For example, a column chart may need **X** and **Y** axes plus an optional **Breakdown**, while a pie chart uses **Values** and **Labels**, and a pivot table requires **Rows**, **Columns**, and **Values**. The panel will guide you on which slots need to be filled for the current visualization.
#### Format Data
Control how your data values are displayed.
Learn more about [Data Formatting](/docs/data-format).
#### Filtering Data
To filter your data while exploring, drag a field into the **Conditions** area, select a comparison operator, and specify the value to apply.
Different field types will have different sets of operators, for example, conditions using a Date field will have operators that are specific for date comparison:
For more on basic filtering operations in Holistics, visit the [Filters](/docs/filters/index.md) documentation page
You can also check out the following for more advanced filter features:
- [Top/Bottom N filter](/docs/top-bottom-n-filter)
- [Filter with Condition Group](/docs/filter-with-condition-group)
#### Limiting data
Sometimes, to speed up the process of exploring data, you only want to see a subset of your complete results from your query. You can do this in Holistics by using our **Row Limit** feature:
Without a set row limit, Holistics supports up to 1,000,000 rows for Data Exploration / Report Creation and 100,000 rows for Data Modeling Preview. When you apply a row limit, Holistics displays the specified number of rows, notifying you of incomplete results.
:::caution
- The Row Limit is directly applied on your query.
- Our sort order is important in these situations: Holistics will first applies the limit, and then applies the sort. For more detail, please refer to the [Sorting data](/docs/charts/data-sort) page.
:::
### Result Panel
This panel displays the final visualization, which is generated from the query sent to the database using the configurations from the Visualization Settings Panel.
### Show Items with No Data
When exploring two models with relationships, it is possible that a value in one model may not exist in another model.
For example, not all customers will have at least one order, so some `users` will not have associated records in the `orders` table. By default, Holistics **will not display these records**:
To include items with no data, you can toggle on the option `Show rows with no data` under `STYLE` > `Others` in our Visualization Settings. Please refer to the video below:
:::tip
You might also want to check out how to [show rows with no data when applying filter](/docs/filters/show-row-no-data-applying-filter).
:::
## Explore with SQL
For adhoc SQL-based data exploration, you can use SQL editor, see [Using SQL for Data Exploration](/docs/query-editor).
---
## Data Formats
## Introduction
When working with tables and charts, you have options to format your data field to suit your display needs, for example: format numbers as currencies, percentage, format dates as weeks, month...
A data format contains 2 components: **Data Type** + **Data Format**.
## Data Types
For each of the value, the first thing we need to decide is their "data type", from there we will choose their respective "formatting". There are 4 main data types:
### 1. Text
Holistics supports 2 text formats: **String** and **HTML**. **HTML** is a special format type can read HTML tags in your string field and render the content of that field. You can make use of it to include images in your table, or turn strings into hyper links.
### 2. Number
Holistics supports 3 types of number formats: **Normal**, **Percentage**, and **Currency**. You can customize the format with [Custom formatting](#custom-formatting).
- **Normal:** This format will have a comma as the thousands separator and 2 decimal places. This is the default format for number fields, which is displayed as **Default**.
- **Percentage:** This format allows you to simply add the `%` symbol to the number, or convert from the decimal value. For example, `0.2` will be `20%`.
- **Currency:** You can customize any currency that you want, but by default, Holistics already supports 28 standard currencies:
- US Dollar ($)
- Euro (€)
- Yen (¥)
- Pound (£)
- Franc (Fr)
- Renminbi (元)
- Lira (₺)
- Won (₩)
- RUS Ruble (₽)
- IND Rupee (₹)
- PAK Rupee (₨)
- MEX Peso ($)
- PHL Peso (₱)
- AUS Dollar (A$)
- CAN Dollar (C$)
- SGP Dollar (S$)
- NZ Dollar (NZ$)
- HK Dollar (HK$)
- Shekel (₪)
- Real (R$)
- Krona (kr)
- Forint (Ft)
- Thai Baht (฿)
- Rupiah (Rp)
- Ringgit (RM)
- VN Dong (VND)
- BGD Taka (Tk)
- ZAR (R)
### 3. Date and Timestamp
`Date` represents just a Date (no time portion) while `Timestamp` shows both a date and time value. You can change them to week, month, quarter or year.
### 4. True/False (Boolean)
A Boolean value of either a True or False. We do not support formatting for this type of data.
## Data format
In Holistics, you can set a field's data format in the **Visualization settings** or in the **Data Modeling** layer. Please note that:
- Different [data types](/docs/data-types) will have different Data formats.
- Data formats only affect how values appear visually in charts and tables. Dashboard interactions like filtering, sorting, and drill-through operate on the original (unformatted) data values.
### Data format in Visualization settings
You can format your data right from the Visualization Settings.
This is useful if you want to replace the default format set in the [Data Modeling](#data-format-in-data-modeling), or if you want to format your business calculation fields.
If you format a field within the visualization settings of one report, that format will not be carried over to other reports. This is the local format setting.
In visualization settings, go to FORMAT. Here you can specify the data type and the data format for any field you drag into visualization settings.
### Data format in Data modeling
:::caution
This option is only available if the underlying types of your Fields (in the database) is either **date** or **number**.
:::
You will be able to reuse the same data format of a field across all reports if you define its format in the data modeling layer. This is the global format setting of any field.
For example, if you set the format of your field **Revenue** to the dollar sign (`$`), it will always be displayed with the `$` symbol as a prefix.
> Holistics supports setting data format for **Number** and **Date** type fields.
To change the data format of a **Number** or **Date** type field:
- Head to Data Modeling > Data Models, click on a **Number** or **Date** field.
- Click on the drop-down box in Format, and choose a format value from the suggested options.
## Custom Formatting
You can also customize the format you need by clicking on **Use custom format**.
### Number
For Number-type fields, you can customize:
- **Abbreviation**: Shorten long numbers by adding suffixes K, M, or B and dividing the number by 1,000, 1,000,000 or 1,000,000,000 respectively. You can choose a fixed unit or set it to `Auto` to let the system intelligently pick the right suffix.
- **Decimal places**: Specify how many numbers are displayed to the right of the decimal point.
- **Separator style**: Specify what separator to use (comma or dot), and the function of the separator (decimal point or thousand separators)
- **Currency**: Add a currency symbol to the number. To display a specific symbol, click **Use custom currency** and enter the symbol you want.
- **Percentage**: Adding the symbol % to the right of the number or convert that number to percentage.
### Date
For Date-type field, you can customize:
- **Date style**: Specify a date style that matches your local style
- **Separator style**: Specify the separator between the time units
If you need help with setting data format or want to request a new data format, please let us know via support@holistics.io. Thank you!
---
## Data Models
## Introduction
A data model is **an abstract representation on top of a database table/SQL query** that you may manipulate without directly affecting the underlying data.
You can also store additional metadata that enrich the underlying data in the data table. Data model contains information about the data itself, such as: model descriptions, field descriptions, and relationships to other models. You can also extend the data model with calculated dimensions and measures.
## Components of a Data Model
A data model consists of:
- **Source of Model:** Whether it's backed by a Database Table, a SQL query or data from another source.
- **Fields:** Including Base Dimensions, Custom Dimensions and Measures
- **Relationships:** Defining how the model connects to other models by specifying the join key and relationship type
- **Persistence:** Improve model loading performance by turning your logical data model into a physical table in your database.
## Types of Data Model
There are two types of data models in Holistics:
- **[Table models](/docs/table-models):** Models created from existing tables in the database.
- **[Query models](/docs/query-models):** Models created by writing SQL to select from tables or other query models
## Create a model
To create a new model, go to the Development, click on the `+` symbol next to a folder, and select the type of model you want to create.
After that, a new screen will pop up to help you finish with your model creation. Depending on the type of the model, the screen will be different:
For more details, please refer to the dedicated documents of each model type.
## Model Metadata
After creating a model, it is good practice to add more metadata to help you (in the future) and other developers to understand the model better.
In this section, we will go through the metadata that you can add to a model.
### Model name
**Model name** is the **unique identifier** of your model within a project.
- For [Table Models](/docs/table-models), the model name is automatically generated.
- For [Query Model](/docs/query-models), you specify the model in the "Input Model Name" box when writing the query.
:::caution
Model names can be changed later, but look out for potential breakage if you have used the names in downstream models, datasets and reports.
Please check our document about [**File name vs. object name**](/docs/development/aml-studio#file-names-vs-object-names) for more details on this topic.
:::
### Model label
**Model label** is the display name for the model that can be more descriptive, or simply looks nicer comparing to a more verbose model name.
For example, you can set the label of model **ecommerce_users** to **Users** :
As a result, this model will be displayed as **Users** in a dataset:
### Model description
**Model description** is another way to provide more context for the underlying data. Model description can be written in **Markdown**:
## Sync schema changes from database table
When you make changes to the schema of the underlying database tables (like adding new columns, delete columns, change column's data type...), you can sync those changes to the model's metadata.
To initiate the sync, navigate to **Development workspace > Your Data Model > refresh Refresh model**.
The effect of this sync is as follows:
- When you **add a new table column**, a new corresponding model field will be added in the model
- When you **delete a table column**, the corresponding model field will be still remain, but Holistics will through an error if you include the field in an exploration.
- When you **rename a table column**, a new model field with the new column name will be added. The field with the old column name will remain, so you need to manually remove it.
- **Changing data type of table columns** in the database will change the data type of the model fields.
- When you **delete the underlying table**, the model itself is unaffected. You can change the source table of the model, but make sure the new table's schema is compatible with the model.
---
## Data Connection Management
## Manage your Data Connections in Holistics
To manage all of your your Data Connections in Holistics, go to [Setting > Data Sources](https://secure.holistics.io/manage/data_sources).
## Data Source Dashboard
### Add new Data Source
Just by simply clicking to `+New Data Source` and then adding necessary configurations for the source you want to connect to Holistics.
### Share With
This column will help you check **which data source** has been **shared** with **which users or user groups**.
Please noted that by default, **admin** users have **full access** to all the **data source** added (modify or share with other analysts), and he/she need to **share** a specific **data source** with **Analyst** Users in order for them to generate reports/dashboards from that shared with them. **Analysts** **cannot edit** any **data source** even though it is shared with them.
### Used In
This column will help you check these **data sources** have been **used** in how many **reports**. Click on the number to see which reports have been generated from this data source
### Status
If **Status** is **OK** => Your Data Source has been **connected successfully**
If **Status** is **Error** => Your Data Source **cannot be connected** at that moment.
You could **hover** to the Fail Status to see the **Error message**.
## Default Schema
Default Schema is the destination in your Data Source that you want to persist or load data from other sources into the current one.
You need to specify your default schema at first and only once before Creating Data Model from 3rd Party Sources or Persisting a result set of a SQL Data Model. However, you could always adjust your Default Schema name on the Data Source page.
With this setting, you don't have to manually select a schema when performing Data Loading.
For example, I selected `persisted_models` as my default schema in the Data Source: khai-postgresql
By doing this, my default destination when performing data loading is persisted_models."table_name"
Please note that if your Data Source does not have the schema (for example, MySQL, you don't have to specify it and feel free to skip this section.
## Interact with your Data Source
### Set a data source as default
You could set a **Data Source** as **Default** so that everytime you add new **Adhoc Query Report**, you will by default **query from your Default Data Source** unless you manually change it inside your Query Report Editor
Please also note that you **cannot set** any **non-relational data source** (e.g. MongoDB) or **third parties source** (Google Analytics, Google AdWords or Pipedrive) **as Default** since you **cannot directly query from them** in Query Report Editor
### Edit a data source
**Edit** your **Data Source configuration** if there is something wrong with it or you want to change the config.
### Share a data source
After successfully connecting your Data Source to Holistics, you could **share** it with **specific users** or **user groups** so that they **could be able to create reports/dashboards from that Data Source**.
You could actually choose to Share the Data Source with all analysts in your tenant
### Delete a data source
To delete a data source/database connection:
1. Go to the **Data Source Management** page.
2. Then, navigation to your data source and select **Delete**.
3. Confirm your selection.
Please note a **data source cannot be deleted** if:
* There are any **Data Models** created in or from that **Data Source**
* These sources are being **used in Data Import or Data Transform**.
* There are **any reports or filters have been queried** from that **Data Source**
* That data source is currently set as **Default**
---
## Data Types
## Holistics's Generic Data Types
Within each data source, the same data types can be implemented in different ways and called by different names. For example, remembering all the different names for integer numbers across all of your data sources requires serious mental efforts.
To ease up the user experience, we implemented a Generic Data Types system that is used across Holistics's UI. They are:
- **Number:** Including both whole and decimal numbers
- **True/False**: Map to boolean data types
- **Date**
- **Datetime**
- **Text**
When interacting with your data via Holistics, you only need to know these data types, and Holistics will automatically map them to the most sensible option in your database.
## Changing data types of fields in Holistics
You can create a [custom dimension](/docs/model-fields) and use your SQL DB's equivalent of the CAST function to create a new field with the correct data type.
e.g. `CAST({{ #THIS.fielda }} as INTEGER)` to change a field data type to a number in MySQL or Snowflake.
---
## DataHub integration
:::warning Early access
This feature is currently in development and not yet available. [Contact Holistics](mailto:support@holistics.io) to sign up for early access.
:::
## Introduction
[DataHub](https://datahubproject.io/) is an open-source metadata platform that helps organizations discover, understand, and govern their data assets. By integrating Holistics with DataHub, you can catalog your BI layer alongside your data warehouse tables, creating a unified view of your entire data stack.
This integration automatically ingests Holistics models, datasets, dashboards, and charts into DataHub, along with the lineage relationships between them. This enables data teams to answer questions like "which dashboards will be affected if I change this database table?" or "where does this metric come from?"
The connector now consumes the **canonical** output of `holistics aml lineage`. That canonical output is a graph of AML-native concepts such as models, datasets, dashboards, viz blocks, source tables, and typed edges between them. The connector then maps the subset relevant to DataHub into DataHub entities.
## How it works
The integration uses a **git-based approach** similar to DataHub's LookML connector. Since Holistics is fully as-code with all assets defined in AML files, the connector reads your AML project directly without needing API access.
Here's how the ingestion process works:
1. **Get your AML project** - Either clone from a git repository or use a local directory
2. **Run the Holistics CLI** - The `holistics aml lineage` command compiles your AML files and outputs a canonical lineage graph
3. **Parse and transform** - The connector parses the canonical `nodes` and `edges` and maps Holistics entities to DataHub entities
4. **Emit to DataHub** - Metadata is pushed to your DataHub instance via the standard ingestion framework
This approach ensures that the metadata in DataHub always reflects what's defined in your AML code, while keeping the CLI output aligned to AML concepts instead of a DataHub-specific schema.
## What gets synced
The connector maps Holistics concepts to DataHub entities as follows:
| Holistics Concept | AML File | DataHub Entity | Subtype |
|-------------------|----------|----------------|---------|
| Model | `.model.aml` | Dataset | View |
| Dataset | `.dataset.aml` | Dataset | Explore |
| Dashboard | `.page.aml` | Dashboard | - |
| VizBlock (chart) | (within page) | Chart | - |
| Dimension | (field in model) | SchemaField | Tagged `holistics:dimension` |
| Measure | (field in model) | SchemaField | Tagged `holistics:measure` |
For each model, the connector extracts schema information including field names, types, descriptions, and whether fields are dimensions or measures. This metadata appears in DataHub's schema tab, helping users understand the semantic layer without leaving the data catalog.
## Lineage
One of the most valuable aspects of this integration is automatic lineage extraction. The connector builds lineage at multiple levels:
- **Dashboard to Charts** - Each chart is linked to its parent dashboard
- **Charts to Models** - Charts reference the specific model fields they visualize
- **Datasets to Models** - Datasets are linked to all the models they include
- **Models to Source Tables** - Table models are connected to their underlying database tables
The canonical lineage graph may also contain additional AML concepts, such as non-viz dashboard blocks or filter-block lineage. The DataHub connector intentionally ignores concepts that do not map to current DataHub entities, rather than requiring the CLI to omit them.
This multi-level lineage enables powerful impact analysis. When someone wants to modify a database table, they can trace through DataHub to see exactly which Holistics models, datasets, and dashboards depend on it.
:::info Connection mapping required
To establish lineage from Holistics models to your source database tables, you need to configure connection mapping. This tells the connector how to translate Holistics data source names (like `bigquery_prod`) to DataHub platform identifiers. See the [setup guide](/docs/datahub-integration/setup#connection-mapping) for details.
:::
## Getting started
Ready to set up the integration? Head to the [setup guide](/docs/datahub-integration/setup) for step-by-step instructions on installing the connector and configuring your first ingestion.
---
## Setup DataHub integration
:::warning Early access
This feature is currently in development and not yet available. [Contact Holistics](mailto:support@holistics.io) to sign up for early access.
:::
## Prerequisites
Before setting up the integration, make sure you have:
- **Holistics CLI** installed and available in your PATH. See the [CLI documentation](/docs/cli/) for installation instructions.
- **DataHub instance** running and accessible. This can be a local instance or DataHub Cloud.
- **Access to your Holistics AML project** - either as a local directory or a git repository.
## Installation
Install the DataHub Holistics connector using pip:
```bash
pip install datahub-holistics
```
This package registers itself as a DataHub ingestion source plugin, so you can use `type: holistics` in your ingestion recipes.
The connector expects a recent Holistics CLI that exposes the canonical lineage graph via:
```bash
holistics aml lineage .
```
## Configuration
The connector is configured through a YAML recipe file, following DataHub's standard ingestion format.
### Basic setup with local directory
If your AML project is on your local machine, use the `base_folder` option:
```yaml
source:
type: holistics
config:
base_folder: /path/to/your/holistics-aml-project
connection_to_platform_map:
bigquery_prod:
platform: bigquery
env: PROD
sink:
type: datahub-rest
config:
server: http://localhost:8080
```
### Git-based setup
For production use, you'll typically want the connector to clone your AML project from git. This ensures you're always ingesting from the latest committed state:
```yaml
source:
type: holistics
config:
git_info:
repo: https://github.com/your-company/holistics-project
branch: main
deploy_key_file: /path/to/deploy_key # Optional, for private repos
connection_to_platform_map:
bigquery_prod:
platform: bigquery
env: PROD
sink:
type: datahub-rest
config:
server: http://localhost:8080
```
### Connection mapping
Connection mapping is essential for establishing lineage from your Holistics models to the underlying database tables. Without it, the connector won't know which DataHub platform corresponds to each Holistics data source.
Each entry maps a Holistics `data_source_name` (as defined in your AML files) to a DataHub platform:
```yaml
connection_to_platform_map:
# Simple mapping - just specify the platform
bigquery_prod:
platform: bigquery
env: PROD
# Detailed mapping - useful for platforms that need more context
postgres_analytics:
platform: postgres
platform_instance: analytics-db
database: analytics
schema: public
env: PROD
# Snowflake example
snowflake_warehouse:
platform: snowflake
platform_instance: my-snowflake
env: PROD
```
The connector uses this mapping to construct proper DataHub URNs for source tables, enabling end-to-end lineage from dashboards down to database tables.
### Feature flags
You can control what metadata gets extracted:
```yaml
source:
type: holistics
config:
base_folder: /path/to/project
# Feature flags (all default to true)
extract_owners: true # Extract owner information from AML
extract_lineage: true # Build lineage relationships
extract_descriptions: true # Include descriptions from AML
include_hidden_fields: false # Include fields marked as hidden
# Platform identification
platform_instance: production-holistics
env: PROD
connection_to_platform_map:
# ... your mappings
```
### Filtering
Use regex patterns to control which entities get ingested:
```yaml
source:
type: holistics
config:
base_folder: /path/to/project
# Only ingest specific entities
model_pattern:
allow:
- ".*"
deny:
- "tmp_.*" # Skip temporary models
- "test_.*" # Skip test models
dataset_pattern:
allow:
- ".*"
dashboard_pattern:
allow:
- ".*"
connection_to_platform_map:
# ... your mappings
```
### Stateful ingestion
Enable stateful ingestion to automatically detect and remove stale entities when they're deleted from your AML project:
```yaml
source:
type: holistics
config:
base_folder: /path/to/project
stateful_ingestion:
enabled: true
connection_to_platform_map:
# ... your mappings
```
## Running the ingestion
Save your recipe to a file (e.g., `holistics_recipe.yaml`) and run:
```bash
datahub ingest -c holistics_recipe.yaml
```
The connector will output progress information showing how many models, datasets, dashboards, and charts were processed.
Internally, the connector calls the Holistics CLI and reconstructs DataHub entities from AML-native graph nodes and edges.
## Verification
After the ingestion completes:
1. **Check DataHub UI** - Navigate to your DataHub instance and search for "holistics". You should see your models, datasets, and dashboards.
2. **Verify lineage** - Open a dashboard and check the Lineage tab. You should see connections to charts, which connect to models, which connect to source tables.
The canonical AML graph may contain additional concepts such as filter blocks or other non-viz dashboard blocks. These are preserved in the CLI output but are not currently emitted as DataHub chart entities.
3. **Check schema** - Open a model and look at the Schema tab. Dimensions and measures should appear as fields with appropriate tags.
## Troubleshooting
**CLI not found**: Ensure the Holistics CLI is installed and in your PATH. Test by running `holistics --version`.
**Git clone fails**: For private repositories, make sure your deploy key has read access and the path in `deploy_key_file` is correct.
**No lineage to source tables**: Verify your `connection_to_platform_map` entries match the `data_source_name` values in your AML models.
**Entities missing**: Check the ingestion report for filtered or errored entities. Adjust your `*_pattern` settings if needed.
---
## Dataset's Best Practices
Dataset is the product of the data builder, and it is the interface that end-users will analyze data. Therefore, you must pay **extra care** when building the dataset.
## What makes a good dataset?
Understanding the *basics* of building dataset is easy: just join together your data models by setting up the relationships and paths.
But there are three things you should be optimizing for when building your dataset:
1. **Correctness:** Do reports built on top of this dataset return correct data? This is the very lowest bar for design, as Holistics makes it fairly easy to get correct results as long as your join conditions are correct.
2. **Performance:** While there may be multiple ways to get Holistics to write a query, there will typically be one way that has the optimal performance. Designing your dataset with performance in mind can be the difference between 15-second and 15-minute dashboard load times.
3. **Usability:** When a user who is not an expert in your data loads your explore, it should be immediately clear how to get the answers they’re looking for.
There is no hard-and-fast rule to develop datasets effectively in Holistics, but here are some recommendations:
## Organizing Datasets in a logical manner
- Here are some typical ways of organizing the dataset:
- Master Datasets: group by entity type (customers, inventory, product). Goal is **completeness**
- Department Datasets. Goal is **relevance**. Ask yourself these questions before building a dataset:
- *“If I'm from this department, how can I find my data quickly?*
- *"Is there a dataset folder that my department will access frequently?”*
- Don't worry about duplicating datasets (there's no harm in having the same data model fields for different teams) - Focus on accessibility and building purpose
- Make it clear when datasets are not meant to be explored (Group into folder)
## Start small then expand the dataset
There seems to be 2 common use cases:
- Building "small" datasets with only a few models that allow users to deep dive into one aspect of the business
- Building “large” datasets that can be used for complex exploration
The best advice we can give is to keep multiple small datasets for "low-requirement" business users and also create large datasets for the more analytics-savvy users to do more complex things and ad-hoc exploration.
### Start out with small datasets which answer specific questions
- Design each dataset to answer a specific set of questions. Anticipate your end users’ questions, and build your dataset around that. Only include the models relevant to the questions.
- It is advisable to have 4-5 models joined together per one small dataset, and 6–7 is the absolute upper limit. If you find yourself needing more, you probably need to model the underlying data more effectively.
- This is because:
- Too many models will confuse end-users
- Too many models will more likely generate complicated & non-optimal joins
### Create large datasets for more complex requirements or ad-hoc exploration
- When the company grows, so should the datasets in order to answer more questions that business users might have. That's why datasets will naturally grow as there are more questions from the business. Continue to include many models in one dataset as long as business users become more confident with data exploration.
- One typical way is creating large datasets related to each business activity like (‘inventory’, ‘sales’, ‘marketing’, ‘risk’), and joining 10-15 tables (or more) one each dataset. On the user navigation end, it’s very simplified, sales users know where to find sales data, and marketing user knows where to find marketing data.
- However, there is a **trade-off between dataset flexibility and performance** and sometimes, there are many incompatible fields which makes it frustrating to figure out what fields can be used together, or not. Here are a few notes to control these large datasets:
- Self-explanatory naming for data models and fields
- Enrich Data models and Fields with metadata
## Avoid complex join paths
- Build your dataset in **“star schema”.** There should be one central table (can be a fact table) that contains necessary measures, surrounded by dimension tables.
- Avoid **“snowflake-schema like” datasets, because** in snowflake schema, additional information about an entity/dimension is separated in their own tables → More models to include in the dataset → more joins are needed to get complete information about a certain entity
- Ideally, set up your models so that the dimensions you need and the measures are only **one JOIN away.** A join chain that is too long will not be performant.
- One good way to reduce the joins generated at dataset run time is to prejoin / denormalize your data as much as possible.
## Performance Tips
### Troubleshooting
:::tip Important
Click [here](/docs/performance/troubleshooting) to learn about Performance Troubleshooting in Holistics.
:::
### Pre-aggregate your data
Instead of exploring from large raw tables, it is better to pre-aggregate and leverage [Aggregate Awareness](/docs/aggregate-awareness) for smoother exploration experience.
### Persist your queries
Query Models may contain costly data computations or transformations. Instead of having to compute the Query Models every time, you can [persist](/docs/query-models#model-persistence) the Query Model results into the database and let Holistics automatically reuse the persisted results.
### Avoid exposing large tables in explorable datasets
It is not advisable to include large tables in an end-user facing dataset. Models creating from large tables (for example, events or log tables) should only serve as a base for you to build other derived models upon.
Large tables often have indexes that are well-known to analysts and engineers. However, end-users of datasets may not know this and can generate poor performance queries while exploring.
### Beware of potential fan-out when creating relationships
When you set relationships between models (**many-to-one** or **one-to-one** ), we assume that the fields at the **"one"** end is already **unique.**
If the field is not unique, when you drag in fields from those two models, a fan-out will happen. The result set will be a Cartesian product of the two models and may "explode" into millions of rows.
### Use Holistics's modeling syntax in Query Models
When writing Query Models, instead of using the normal `model_alias.field_name` notion to select fields from tables, use `{{ #model_alias.field_name }}` so Holistics will only select the necessary fields to be included in the generated queries. [Learn more](/reference/aml/query-model#query-syntax)
### Optimize your Data Warehouse
Please refer to this [doc](/docs/performance/troubleshooting#improvement-methods).
## Other Notes
- You can create any number of datasets because Holistics datasets has the below 2 benefits
- No need to worry about report sprawl. All definitions are centralized within Holistics.
- All Holistics datasets are virtual and they don't take up physical storage space.
- Common Challenges:
- Data exploration offers flexibility for business users, but there is the risk of dragging in incompatible dimensions and metrics, or adding non-additive metrics.
- Beware of [Fanout Issues](/docs/joins/troubleshooting-fanout).
---
## Dataset's Custom Views
:::info Note
This feature is available from Standard plan and above.
:::
## Introduction
**Dataset views** allow admins/analysts to **customize how datasets are presented to end users** without changing the underlying models, fields, or metrics.
This makes datasets more user-friendly because end users now only see relevant, curated fields.
## How it works
By default, in the Exploration view, all models, fields and metrics you defined will be displayed. Furthermore:
- **Models** are displayed by the order listed in dataset files.
- **Fields** are grouped by dimensions and measures, then displayed by the order listed in the model files.
- **Metrics** are displayed in a special group on top of the models
Before
By creating a dataset view, analysts can:
- **Reorder** fields and models to highlight frequently used models and fields, or indicating a hierarchy.
- **Display only relevant** models and fields to simplify large datasets with multiple models and fields.
- **Group** relevant models, fields and metrics to aid navigation.
After
When a dataset has a custom view, the view is displayed by default. This makes it easy for end users to get used to the dataset. You can switch to the original view if you prefer to.
## Syntax
Dataset view is declared with the `view` keyword. Properties to be included are declared with the form `property_type property_name`:
```aml
Dataset dataset_name {
...
view {
// Display a subset of models (model_1, model_2)
model model_1 {
// Display a subset of fields
field dimension_1
field measure_1
// Declare a group of fields
group field_group_name {
field dimension_2
field measure_2
}
}
model model_2 { } // Leave the inside blank to display all fields in this model
// Declare a group of models
group model_group_name {
model model_4 { }
model model_5 { }
}
// Declare a group of metrics
group metric_group_name {
metric metric_1
metric metric_1
}
}
}
```
Example view of ecommerce dataset:
```aml
Dataset ecommerce {
...
models: [users, products, orders, dim_dates]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(orders.product_id > products.id, true),
relationship(orders.order_date > dim_dates.id, true)
]
view {
model products { } // Display all fields from Products
model users {
// Display selected fields from Users
field id
field email
field age
// Create group "Customer Name" containing fields within model Users
group customer_name {
field first_name
field last_name
field full _name
}
}
// Create group "Order Master" containing two models Orders and Dates
group order_master {
model orders {
field order_value
field id
field order_status
field order_discount
}
model dim_dates {
field year
field month name
field day
field week_number
field quarter
}
}
}
}
```
## Hierarchy of a properties
The following properties are accepted in the view definition:
- `model`: to specify models to be included in the view.
- `field`: to specify model fields to be included. `field` covers both dimensions and measures.
- `group`: within a model, this is used to create a group of fields.
- `group`: to create a group containing other objects, like `model` and `metric`.
The hierarchy of the properties can be visualized as follows:
```
Dataset
└── view
├── model
│ ├── field
│ └── group
│ └── field
├── group
│ └── model
│ ├── field
│ └── group
│ └── field
└── group
└── metric
```
Notes:
- `metric` must be declared in a `group`.
- Currently we don't support declaring fields without a container model. That means you cannot do this:
```
Dataset
└── view
└── group
└── model.field
- "Group of groups" is not allowed. In other words, the following structure **is invalid**:
```
Dataset
└── view
└── group
└── group
```
## Using Models from Modules in Dataset Views
When working with models defined in [AML modules](/reference/aml/module), you need to import them using the `use` keyword before referencing them in your dataset view.
Unlike other parts of the dataset definition where you can use the `module.model` syntax, dataset views require explicit imports to reference models from modules.
### Syntax
To use models from a module in your dataset view:
1. Import the models using the `use` statement at the top of your dataset file
2. Reference the imported models directly in your view definition
```aml
use module_name { model_1, model_2 }
Dataset dataset_name {
models: [model_1, model_2]
view {
model model_1 { }
model model_2 { }
}
}
```
### Example
Let's say you have a project structure like this:
```
📁 project/
📁 models/
📁 datasets/
📄 ecommerce.dataset.aml
📁 modules/
📁 sales/
📄 orders.model.aml
📄 products.model.aml
📁 customers/
📄 users.model.aml
```
You can import and use models from the `sales` and `customers` modules in your dataset view:
```aml
use sales { orders, products }
use customers { users }
Dataset ecommerce {
label: 'Ecommerce Dataset'
data_source_name: 'demodb'
models: [orders, products, users]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(orders.product_id > products.id, true)
]
view {
model products { } // Display all fields from Products
model users {
// Display selected fields from Users
field id
field email
field full_name
}
// Group models together
group order_details {
model orders {
field id
field order_value
field order_status
}
}
}
}
```
---
## Relationships in Dataset
## Introduction
When users use fields from different data models, based on the relationships metadata, Holistics will figure out the correct JOINs statements to apply to the SQL query.
For example, you have a dataset with the following models: `orders`, `users`, `merchants`, `cities`, `countries`
To calculate **"Total orders by customers' countries"**, you will use the two fields `countries.name` and `orders.total_orders` from the `countries` and `orders` models:
The generated query will be:
```sql
SELECT
T3."name" AS "name",
count(1) AS "total_orders"
FROM
"ecommerce"."orders" T0
LEFT JOIN "ecommerce"."users" T1 ON T0."user_id" = T1."id"
LEFT JOIN "ecommerce"."cities" T2 ON T1."city_id" = T2."id"
LEFT JOIN "ecommerce"."countries" T3 ON T2."country_code" = T3."code"
GROUP BY 1
ORDER BY 2 DESC
```
## Path Ambiguity
When working with complex datasets, you may encounter situations where multiple join paths exist between models. For example, connecting `countries` to `order_items` could go through either customers (via users and orders) or merchants (via merchants and products), each producing different business results.
Holistics automatically resolves these ambiguous paths using an intelligent ranking algorithm that considers relationship patterns, explicit specifications, and path complexity. The system selects the most analytically appropriate path at query time, allowing you to keep all relationships active without manual intervention.
For detailed information about how path ambiguity works, including the automatic resolution algorithm, common scenarios (like role-playing dimensions and galaxy schemas), and manual control options, see [Path Ambiguity in Dataset](/docs/joins/path-ambiguity).
## Other notes
### How JOINs are constructed
By default, Holistics uses `LEFT JOIN` for both `many-to-one` and `one-to-one` relationships. For `many-to-one`, the model on the "many" side goes on the left of the join. Relationships marked with `nullable=false` generate an `INNER JOIN` instead, which is faster but assumes the keys always match.
For more details, see [How Holistics handles joins](/docs/joins/how-joins-work#how-we-generate-sql-based-on-different-join-types) and [Opting into INNER JOIN with nullable=false](/docs/joins/how-joins-work#nullable-relationships).
### 'SELECT DISTINCT' in the SQL
In some cases, you will see we apply SELECT DISTINCT in the underlying query for two reasons.
First, this is part of our mechanism to avoid fan-out issue. For more information, please refer to our document about [fan-out issue](/docs/joins/troubleshooting-fanout)
Second, we see no value to display all records (even duplicated records) so we have applied SELECT DISTINCT to prevent showing redundant data. Our aim is to provide dataset's explorers an overview of their data.
---
## Dataset
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Data Model in Holistics](/docs/data-model)
- [Create Relationships between Data Models](/docs/relationships)
:::
## Introduction
In Holistics, a **Dataset** is a "container" holding several [data models](/docs/data-model.md) together so they can be explored together, and dictating which join path to be used in a particular analytics use case.
In other words, Dataset is like a mini **data mart** that enables:
- **Data Exploration:** Dataset can be shared to Explorers (non-technical users) to do self-service exploration of the data.
- **Creating Charts:** All Charts in Holistics have to be created from a dataset.
## Create a dataset
To create a new dataset, go to the Development, click on the `+` symbol next to a folder, and select **Add Dataset**.
A new screen will appear, and here you can add more details to your datasets and select models to be included.
After clicking **Create Dataset**, the new dataset will appear in your the folder tree as a file with the following format: `my_dataset_name.dataset.aml`.
Alternatively, you can also write the `.dataset.aml` file from scratch. In the next section, we will briefly go through the items in the dataset's code representation.
## Dataset components
:::info Reference
Please refer to [AML Dataset Reference](/reference/aml/dataset) to learn more about all available parameters and their example usage.
:::
In general, a dataset has the following components:
- **Dataset metadata:** dataset labels, descriptions, owners, and the data source that the dataset will query from.
- [**Data models**](/docs/data-model) included in the dataset.
- [**Relationships**](/reference/aml/relationship) between the included models.
- [**Metrics**](/as-code/aql/learn/what-aql-is-for) for complex aggregations.
- [**Dimension**](/docs/dimensions-in-datasets) for complex cross-model reporting use cases.
- [**Dataset Views**](/docs/datasets/custom-views) for customizing the look of the dataset.
Putting together, a dataset definition has the following form:
Dataset definition
```aml
// demo_ecommerce.dataset.aml
Dataset simple_dataset {
// Basic metadata
label: '[Demo] Ecommerce'
description: 'Demo dataset for E-commerce use cases test'
owner: 'demo@holistics.io'
data_source_name: 'demodb'
// List of models in the dataset
models: [
orders,
order_items,
users,
products,
categories
]
// Relationships between models
relationships: [
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(products.category_id > categories.id, true)
]
// Metrics
metric count_orders {
label: 'Count Orders'
type: 'number'
definition: @aql orders.id | count() ;;
}
metric sum_order_value {
label: 'Sum Order Values'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price) ;;
}
metric average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql sum_order_value / count_orders;;
}
// Dimension
dimension acquisition_month_cohort {
model: users
type: 'date'
label: 'Acquisition Month Cohort'
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
// Dataset view definition
view {
model orders { }
model users { }
group relevant_models {
model products { }
model categories { }
}
group business_metrics {
metric sum_order_value
metric average_order_value
}
}
}
```
## Interactions with dataset
### Explore dataset
Users can easily explore their data with the familiar drag-and-drop interface, and save the result as a widget in a dashboard.
You can also explore a report / dashboard widget's result by clicking on it and choose "Explore Data". For more details, please refer to [Explore Data](/docs/data-exploration).
### Share dataset
You can share datasets to specific users or groups. Note that while you can group dashboards or reports into a folder and share them all at once, this is not supported for datasets — each dataset must be shared individually. This is because datasets represent the underlying data model and can expose large portions of your database or multiple tables at once.
## Best practices
For best practices on creating, organizing and optimizing performance of datasets, please refer to [Best Practices](/docs/datasets/best-practices).
---
## Database Setup for Timezone Settings
In order to use our [Timezone Settings](/docs/datetimes/timezones.md), some special databases require an additional setup for timezone information.
## Snowflake
Holistics needs to get the correct data source timezone of your Snowflake to apply the Timezone Settings feature:
- **Step 1:** Log in to **Snowflake Console** using the same user at Holistics
- **Step 2:** Run the query below to get the timezone for that user:
```sql
SHOW PARAMETERS LIKE 'TIMEZONE%';
```
The result will look like:
- **Step 3:** Use the timezone in the **value** column to set the data source timezone at Holistics:
:::caution
Setting this value to anything other than the timezone that your database is in may lead to unexpected results.
:::
## MySQL
MySQL requires a timezone table to use the Timezone Settings feature. This can be run by an admin:
- **Step 1**: Admins need to run the following command on your MySQL database server:
```sql
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql
```
- **Step 2**: After that, try running some timezone-related queries to ensure the timezone table is loaded properly. For example:
```sql
SET time_zone ='Asia/Singapore';
```
You might **need to restart your MySQL server** in order for the changes to take effect.
- **Step 3**: Log in to Holistics and test the database connection:
If you have connected to your MySQL before, you need to go to the **Data Source Management > Edit data source > Re-test your connection**:
For more information, you can read more in the [MySQL documentation](https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html).
:::caution
In case the timezone information changes (i.e: your country decided to officially switch to a new timezone, excluded Day-light Saving Time), the applications that use the old rules become out of date and you need to reload the timezone tables to keep the timezone information up to date.
:::
---
## Extract Date Parts from Datetime
## Introduction
Need to analyze your data by different time periods? Holistics supports **Date Parts** to extract specific date/time components from your timestamps.
This feature lets you group and analyze your data by:
- Day of Week (e.g., Monday, Tuesday)
- Month of Year (e.g., January, February)
- Quarter of Year (e.g., Q1, Q2)
- Day of Month (1-31)
- Hour of Day (0-23)
- ISO Week (W1-W53)
## Supported functions
Here's what you can extract from a timestamp like `2021-06-10 14:19:15`:
| Date Parts functions | Output |
| --- | --- |
| By Quarter of Year | Q2 |
| By Month of Year | June |
| By ISO Week | W23 |
| By Days of Week | Thursday |
| By Days of Month | 10 |
| By Hour of Day | 14 |
Example of how you can slice total orders by month of the year in a pivot table.
---
## Natural Time Expression
:::tip Knowledge Checkpoint
This documentation assumes you are familiar with the following concepts:
- [Complete time period](/docs/filters/date-filters#complete-and-incomplete-time-periods)
:::
# Introduction
Using the [matches](/docs/filters/date-filters#matches) filter operator and our natural time expressions, you can construct flexible time filtering conditions for your report. We categorize the time expressions by their functionality:
- **Select exactly one time unit:** The expression helps users selecting one particular time unit, like a certain year, day, or hour.
- **Select a time span:** The expression helps users selecting more than one time unit (like 2 years, 3 days, 4 hours), or a range from one to another time point.
:::info Time unit
The **time units** we currently support are: `year(s)`, `quarter(s)`, `months(s)`, `week(s)`, `day(s)`, `hour(s)`, `minute(s)`, `second(s)`
:::
## Select exactly one time unit
### Absolute time expressions
Pattern
Example
Time covered
Notes
YYYY
2024
All time in the year 2024
MMM YYYY, MMMM YYYY
Apr 2024, April 2024
All time in April of 2024
DD MMM YYYY, MMM DD YYYY
30 April 2024, April 30 2024
2024-04-30
DD-MM-YYYY, YYYY-MM-DD
30-04-2024, 2024-04-30
2024-04-30
DD/MM/YYYY, YYYY/MM/DD
30/04/2024, 2024/04/30
2024-04-30
HHHH:MMHH:MM:SS
1515:0015:00:30
From the beginning the time expression to just before the next smallest unit of the expression starts.15:00 covers 60 seconds from 15:00:00 to just before the next minute (15:01:00) starts, because minute is the smallest unit mentioned.
Without a [date] part, the time is interpreted as of the current date.
[date] [time]
2024-04-30 152024-04-30 15:002024-04-30 15:00:30
The time period of the mentioned date
Only accepted [date] formats: DD-MM-YYYY, YYYY-MM-DD, DD/MM/YYYY, YYYY/MM/DD.
### Relative time expressions
Relative time expression's resolution to an exact time will depend on the current time.
Pattern
Example
Time covered
Notes
beginning
1970-01-01
today
Today is 2024-04-01, and we use today
2024-04-01
yesterday
Today is 2024-04-01, and we use yesterday
2024-03-31
tomorrow
Today is 2024-04-01, and we use tomorrow
2024-04-02
now
Today is 2024-04-01, the current time is 15:00:30, and we use now
2024-04-01 15:00:30
this [time unit]
Today is 2024-04-15, and we use this month
All time within Apr 2024
Cover all time within the same [time unit] with the current moment.
The current time is 2024-04-15 15:30:00, and we use this hour
All time within 2024-04-15, 15pm
[weekday] this week
Today is Thursday 2024-04-18, and we use monday this week or mon this week
2024-04-15
[weekday] last x week(s)
Today is Thursday 2024-04-18, and we use monday last 1 week
2024-04-08
last/next keywords behave the same as the last/next operators
[weekday] next x week(s)
Today is Thursday 2024-04-18, and we use monday next 1 week
2024-04-22
[time period] begin
Today is 2024-04-15, and we use this month begin
2024-04-01
[time period] = this/next/last + [quantity] + [time unit].Examples of [time period]: this year, this month, last 2 weeks, this hour, next 10 minutes...
The current time is 2024-01-01 15:30, and we use this hour begin
One second from 2024-01-01 15:00:00 to before 2024-01-01 15:00:01 starts
[time period] end
Today is 2024-04-15, and we use this month end
2024-04-30
The current time is 2024-01-01 15:30, and we use this hour end
One second from 2024-01-01 15:59:59 to before 2024-01-01 16:00:00 starts
x [time unit] ago
Today is 2024-04-15, and we use 5 days ago
2024-04-10
Cover a time unit that happened x complete time units before now.
The current time is 2024-04-15 15:30:00, and we use 5 hours ago
All time within 2024-04-15, 10am to 11am
x [time unit] from now
Today is 2024-04-15, and we use 2 days from now
2024-04-17
Cover a time unit that happened x complete time units from now.
The current time is 2024-04-15 15:30:00, and we use 5 hours from now
All time within 2024-04-15, 20pm to 21pm
:::tip `begin` and `end`
`begin` and `end` keywords are used to return the first and last unit of a time range. They can be used in combination with time span expressions to create very flexible exact-unit selection.
These exact-unit selection can also be used as the **start** and **end** for even more flexible time span selection.
:::
### Examples
- Year: 2024
- Month: Apr 2024 / April 2024
- Date: 30 April 2024, 2024-04-30
- Relative dates:
- today, tomorrow, yesterday, this day
- Monday this week, Monday last 2 weeks, Tuesday next week, Tuesday next 2 weeks
- this month begin, this month end; this week begin, this week end
- 3 days ago / last 3 days begin
- next 2 day end: returns the date of two complete days from the current date
- Date time: 2024-04-30 15:30:00
- Relative time:
- now
- 1 hours ago, 20 minutes ago, 30 seconds ago
- 1 hour from now, 20 minutes from now, 30 seconds from now
## Select a time span
These expressions help users to select a time range between two points, or select a span of N time units.
Pattern
Example
Time covered
Notes
[start] -/to [end]
Today is 2024-04-15, and we use 2024-04-15 - this month end or today to this month end
All dates from 2024-04-15 to 2024-04-30
Cover all time between two points including the end point.[start] and [end] can be any exact-unit expression. Their time granularity can be different.
Current time is 2024-01-01 15:35:00, and we use last 3 hours to this minute
All time from 2024-01-01 12:00:00 to (and including) 2024-01-01 15:35
[start] till/until [end]
Today is 2024-04-15, and we use today till this month end
All dates from 2024-04-15 to 2024-04-29
Cover all time between two points, excluding the end point.[start] and [end] can be any exact-unit expression. Their time granularity can be different.
Current time is 2024-01-01 15:35:00, and we use last 3 hours to this minute
All time from 2024-01-01 12:00:00 to before 2024-01-01 15:35:00 starts
last x [time unit]
Today is 2024-04-15, and we use last 2 months
All dates in Feb 2024 and Mar 2024, since Apr 2024 is incomplete
Cover all time in the previous x complete units.
Current time is 2024-01-01 15:35, and we use last 3 hours
All time from 2024-01-01 12:00 to (and including) 2024-01-01 14pm
next x [time unit]
Today is 2024-04-15, and we use next 2 months
All dates in May 2024 and Jun 2024, since Apr 2024 is incomplete
Cover all dates in the next x complete units.
Current time is 2024-01-01 15:35, and we use next 3 hours
All time from 2024-01-01 16:00 to (and including) 2024-01-01 18pm
### Rolling range shorthands
Short aliases for common "from start of period to today" ranges:
| Shorthand | Full form | Time covered |
|-----------|-----------|--------------|
| `ytd` | `year to date` | From Jan 1 of the current year to today |
| `mtd` | `month to date` | From the 1st of the current month to today |
| `wtd` | `week to date` | From the start of the current week to today |
These are additive - existing long-form phrases (`year to date`, `month to date`, `week to date`) continue to work.
### Examples
- [start] and [end] in the same unit:
- April 15 2024 to may 15 2024, 2024-04-15 to 2024-05-15
- April 15 2024 until may 16 2024, 2024-04-15 till 2024-05-16
- 30 april 2024 12:00 till 30 april 2024 14:00
- last 3 days, next 3 weeks
- last 10 hours, next 30 minutes
- Rolling ranges: `ytd`, `year to date`, `mtd`, `month to date`, `wtd`, `week to date`
- [start] and [end] in different units:
- 2023 to september 2024
- 2 months ago to monday last week
- this year begin to this week end
- yesterday to now
- this weekend to next 2 months
## Caching implications
When using relative time expressions, your SQL will be dynamically generated according to the current time.
Therefore, relative time expressions can affect how Holistics caches your SQL results.
For example, let's say we have this filter that uses `now`
When we open the report (containing the above filter) at `2024-09-17 01:09:38.000 -07:00`, the report generates a SQL with this WHERE clause:
```sql {6}
WHERE
(CAST ( "order_master"."order_created_at" AS timestamptz )
>= CAST ( '2024-09-14T00:00:00.000-07:00' AS timestamptz ))
AND
(CAST ( "order_master"."order_created_at" AS timestamptz )
< CAST ( '2024-09-17T01:09:38.000-07:00' AS timestamptz ))
```
Then when we open the report at `2024-09-17 01:17:33.000 -07:00`, the report generates another SQL with this WHERE clause:
```sql {6}
WHERE
(CAST ( "order_master"."order_created_at" AS timestamptz )
>= CAST ( '2024-09-14T00:00:00.000-07:00' AS timestamptz ))
AND
(CAST ( "order_master"."order_created_at" AS timestamptz )
< CAST ( '2024-09-17T01:17:33.000-07:00' AS timestamptz ))
```
Because the above 2 SQLs are different, Holistics will **not** re-use cache in the latter run, _regardless of the report's cache settings_.
If we used `today` instead of `now`:
The report would generate this **same** SQL when we visit it at **both** `2024-09-17 01:09:38.000 -07:00` and `2024-09-17 01:17:33.000 -07:00`:
```sql {6}
WHERE
(CAST ( "order_master"."order_created_at" AS timestamptz )
>= CAST ( '2024-09-14T00:00:00.000-07:00' AS timestamptz ))
AND
(CAST ( "order_master"."order_created_at" AS timestamptz )
< CAST ( '2024-09-18T00:00:00.000-07:00' AS timestamptz ))
```
And Holistics would be able to re-use cache in both runs.
:::tip Conclusion
Be mindful of the caching implications when using Natural Time Expression.
Only use high-freshness values like `now` when you really need high freshness.
Otherwise, you can choose lower-freshness values like `today`, `this hour`, `last hour`, etc.
:::
Learn more about Holistics Report Caching [here](/docs/performance/data-caching#how-holistics-cache-data).
---
## 🌎 Timezone Settings
## Introduction
Dealing with timezones can be a challenging task, and it's a complexity that many Business Intelligence (BI) tools grapple with, including our own. This document provides a comprehensive overview of how we manage timezone calculations.
**The Problem:** Your database typically records timestamps in a specific timezone, often in UTC. However, there might be a need to process data in a different timezone, such as the one your business operates in.
Let's walk through a few examples where this discrepancy might impact your reporting system.
### Problem 1: Aggregating Timestamp Into Daily Metrics
In a scenario where we aim to aggregate timestamps to calculate "Daily Orders," and given that your e-commerce business operates in Vietnam (UTC+7), the use of a UTC+0 timezone and UTC+7 time reference will yield two different results:
- An order placed at `2020-01-02 23:00:00 UTC` should belong to `2020-01-03` for your business, as it happens at 6 am the next day UTC+7.
- When aggregating daily orders, that order should count towards `2020-01-03` instead of `2020-01-02`.
### Problem 2: Resolving Relative Dates
Users can use [Relative Date Syntax](/docs/datetimes/relative-dates) to input expressions like `yesterday` or `1 week ago`, and Holistic will translate them into precise dates.
However, if you are in timezone UTC+7, the expression `today` resolves to `n+1`, and `yesterday` resolves to `n`.
## Solution: Holistics’s Timezone Settings
Holistics converts timestamps and relative dates to the specified timezone by detecting the database timezone and rewriting SQL queries accordingly. The underlying data remains unchanged, and the query results are adjusted using Holistics's timezone settings, providing a tailored and reliable experience with a variety of timezone options.
### 1. Organization Timezone
To change the default timezone for all explorations and reports, go to Admin Settings > General Settings > Organization Timezone:
### 2. Dashboard Timezone
For companies with teams in different countries, the Dashboard Timezone feature allows each team to view data in their local timezones, overriding the Organization Timezone at the dashboard level.
This enables tailored dashboards, such as creating one for Vietnamese teams with their local timezone (UTC+07:00) while other reports and dashboards maintain the default Organization Timezone (UTC+08:00).
#### Dynamic Dashboard Timezone
You can enable viewers to flexibly change the timezone when viewing a dashboard by toggling the switch below.
To prevent unnecessary dashboard loading when users initially open it, we advise [turning off the Dashboard Auto-load function](https://community.holistics.io/t/dashboard-lazy-loading-do-not-run-dashboard-on-first-load/839/9).
### 3. Embedded Analytics Timezone
For timezone settings for Embedded dashboards, please visit [Embedded Analytics Timezone Settings](/embedded/single-dashboard/basic-settings#timezone-settings) doc.
:::caution
Our Timezone Settings are not applied to **Adhoc Query** and **Transform Model Creation View:**
- We keep your original query untouched to ensure that the data result matches the query logic. It not only enables the users to access raw data with minor modification but also allows them to define any query logic without interfering with Holistics logic.
- However, the settings still affect all of your reports although they are built from transform models.
:::
## Supported Databases
We support **all DBs** for this feature. Below details the database-specific notes:
### DBs that have version constraint
- Microsoft SQL Server (from version 2016)
- Clickhouse (from version 1.1.54362, released on 2018-03-11)
### DBs that require additional setup
- [Snowflake](/docs/datetimes/database-setup-for-timezones#snowflake)
- [MySQL](/docs/datetimes/database-setup-for-timezones#mysql)
### Differences between Supported and Unsupported Databases
Date Filter
Query Processing
Holistics Internal Processing
Supported Databases
Use your Organization Timezone
Use your Organization Timezone
Use your Organization Timezone
Unsupported Databases
Use your Organization Timezone
Use your default Database Timezone
Use UTC
## FAQs
### I want to skip the timezone conversion for specific columns, what should I do?
Currently, Holistics automatically converts all your time-based data, but we are working on a feature that will allow you to skip the conversion for certain columns. Stay tuned for updates!
### Is this feature supported for all Holistics versions (2.0 / 2.5 / 2.7 / 3.0 / 4.0)?
Due to differences in the working mechanism, we only support the new Timezone Setting for Holistics version 3.0 onwards.
### I am on version 3.0+, but I cannot see this feature available on my end. What should I do?
This feature might be disabled for the following reasons:
1. You are using unsupported database versions, or haven’t set up your database yet: [Supported Databases](/docs/datetimes/timezones#supported-databases).
2. Your team already converted the timezone outside Holistics and asked us to skip this feature for your account.
---
## Week Start Day
## Introduction
By default, Holistics considers Monday as the "start of the week". If this does not suit your organization's definition of week, for example, you want Sunday to be the first day of the week, you can easily change it with our **Week Start Day Setting**.
You can find this setting in **Admin Settings > Week Start Day** section:
:::caution
Changing Week Start Day Setting will affect the underlying SQL queries being generated for every report, thus their results. Please do this carefully and considerately.
:::
## How Week Start Day affects your report
This setting will have effect in the following scenarios:
- Processed date range in Week aggregation
- Relative week resolution: Resolving text like `this week`, `last 2 weeks` into absolute date ranges.
### Processed date range in Week Transformation
When you have a date or time field, and you want to report a measure aggregated to Week grain, Holistics will get the week value out of your date-time value, and display the start-end dates of that week. Different **Week Start Day settings** will produce different processed date ranges:
**Week Transformation** is used in different places in Holistics:
- Data model preview, Data exploration:
- Metric Sheet:
- Date Drill:
### Relative week resolution
The Week Start Day Setting also affects how Holistics resolves "relative week" filter values into actual date range.
For example, given **today is 19th August 2019 (Wednesday):**
- If your weeks start on Monday, `last week` will be resolved into a date range from **10th August 2019 (Monday)** to **16th August 2019 (Sunday)**.
- However, if your weeks start on Sunday, the result will be totally different.
## FAQs
**1. I have changed our Week Start Day from Monday to Sunday, but I found nothing changes. What should I do?**
After changing your week definition in **Admin Settings**, please refresh your dashboard cache to apply new week start day.
If the new setting is still not applied, please send us a support ticket via support@holistics.io.
**2. Our Holistics account is on version 2.0 / 2.5 / 2.7. How can we try the Week Start Day Setting?**
Currently, we only support this setting on Holistics 3.0 onwards. If you are on previous versions and want to try this feature, please share your case via support@holistics.io.
---
## Integrate with dbt Cloud
## Introduction
This document provides information on how to use **dbt integration feature** to set up automatic metadata sync between dbt Cloud and Holistics.
## Pre-requisites
- You need to have access to the **[dbt Cloud API](https://www.getdbt.com/pricing/)**.
## How it works
- **dbt Service Account Token**: This token enables secure authentication with the dbt Cloud API given that you grant each token a narrow, controlled set of permissions. You can learn more about it [here](https://docs.getdbt.com/docs/dbt-cloud-apis/service-tokens).
- **dbt Cloud job**: a set of dbt commands to execute against a data source. This is created in dbt Cloud.
- **dbt integration job**: an operation in Holistics to ensure that the metadata between dbt models and Holistics models are in sync
- By linking a dbt Cloud job with a data source in Holistics, we can get the metadata after dbt commands are run on that data source
## Step-by-step instructions
### Step 1: Generate dbt Cloud Service Token with read-only permissions
Remember to copy the Service Token that you have generated!
### Step 2: Configure dbt integration in Holistics
- Go to Holistics Settings > Integrations > dbt Integration.
- Add dbt Cloud connection.
- Add the Service Account Token that you have created in Step 1.
- Add Account ID (can be retrieved in the URL `https://cloud.getdbt.com/next/settings/accounts/{{account_id}}`.
- Add Custom URL (or Access URL in dbt): can be found via [this guide](https://docs.getdbt.com/docs/cloud/about-cloud/access-regions-ip-addresses#api-access-urls).
- Add dbt Integration job.
- Add the dbt Job ID that you want to sync dbt metadata with Holistics
- There could be multiple Job IDs that run on different environments and target different data sources/schemas. So you need to choose which Job ID you want to sync with Holistics
- You can get the dbt Cloud job by going to that Job in dbt Cloud and checking via the URL `https://cloud.getdbt.com/next/deploy/{{account_id}}/projects/{{project_id}}/jobs/{{job_id}}`
- Link that dbt Integration Job with a Data Source that you have already connected in Holistics.
- Connect and Execute.
- After the dbt Integration Job executes successfully, all the metadata (descriptions, dependencies graph) in dbt will be synced automatically with related **Holistics Table Models** via the **Data Source name** (done in step 5.2) and **Source Table Name**. This is the same with [dbt CLI integration](core).
## Removing dbt Cloud Connection
:::info
Please note that removing the dbt Cloud connection only **discontinues the automatic sync between dbt Cloud and Holistics models**. Therefore, any previously synced metadata will continue to exist in Holistics. We do not support an option to entirely erase the synced metadata from dbt at the moment.
:::
To remove dbt Cloud connection, go to `Settings` > `Integrations` > `Update dbt Cloud connection` > `Remove dbt Cloud connection`
### Using your metadata
If you prefer not to use the metadata from dbt, you can easily supersede it by adding your descriptions to the model. By inputting descriptions for models and/or dimensions, the system will automatically overwrite the existing dbt description.
---
## Integrate with dbt Core
## High-level Concept
How this works on the high level:
- dbt is run and generates a `manifest.json` file that contains metadata related to the dbt project.
- Users can push dbt's `manifest.json` file to Holistics using Holistics CLI
- Holistics links metadata to the corresponding Holistics data models with the same underlying data tables generated by dbt.
Some of the metadata from dbt that can be presented in Holistics includes Tables description, columns description, dbt's lineage diagrams.
## Setting up
### Step 1: Install & Configure Holistics CLI
Currently, Holistics only supports pushing dbt metadata to Holistics via CLI.
Please refer to the [Holistics CLI doc](/docs/cli) to set up the CLI for Holistics. Make sure you generate an API Access Key and authenticate the CLI with that key.
### Step 2: Pushing manifest.json file to Holistics
`manifest.json` is a JSON file generated by dbt build process. The file contains all metadata related to your dbt project.
Depending on your situation, use an appropriate DBT command to generate your `manifest.json` file:
:::info What dbt command should I use to generate manifest.json?
- If this is the first time running the project, and your dbt models have not been materialized, use `dbt run` or `dbt build`.
- If you have just made a change to your dbt project, use `dbt run` or `dbt build`.
- If you have run your dbt project, materialized dbt models and **there is nothing you want to change**, use `dbt compile`.
:::
Once you have the latest manifest file generated, run:
```bash
$ holistics dbt upload --file-path manifest.json --data-source=your_ds_name
```
The above command requires 2 params:
- `--file-path`: path to your manifest.json file
- `--data-source`: your data source name in Holistics
Each manifest file will be linked to a specific data source in Holistics.
If you have successfully pushed the file manifest.json to Holistics, you can go to our admin panel to check the status of your manifest.json file.
### Step 3: Create the table models in Holistics UI
At this stage, all of your metadata from dbt has been propagated into Holistics. Table Models that share the same data tables with dbt will automatically display the metadata (defined in dbt docs)
If you don't have any models from dbt tables yet, you can create the table model as normal. Holistics will auto-detect if that table is linked with a dbt model.
Consider the following `users_summary` Holistics model that sits on top of `users_summary` table (created by dbt)
```aml
Model users_summary {
data_source: ''
table: 'bi.users_summary'
dimension id {
type: 'number'
}
dimension username {
type: 'text'
}
dimension full_name {
type: 'text'
}
}
```
When viewed in Holistics UI, the descriptions of the fields are automatically pulled in from dbt metadata
### Overriding field's description
If you want to set a custom description and override the ones defined in dbt, just set the value of `description` attribute of the field.
```bash
Model users_summary {
table: 'bi.users_summary'
...
dimension full_name {
...
description: 'Full name of the user' # This will override the value from dbt
}
}
```
## FAQs
### If I update my model's metadata in Holistics, will it propagate back to dbt docs?
No. The direction is only one way (from dbt docs → Holistics).
### What is the relationship between the dbt model and Holistics' table model for the same underlying database table?
No direct relationship. Remember that there are 2 different concepts:
- dbt model: A SQL query that gets persisted into a database table.
- Holistics' Table Model: An abstraction on top of an existing database table.
### I have updated the dbt's metadata, will they automatically sync with Holistics?
No. Currently, we don't support this yet.
If you have updated your dbt's metadata, you will need to re-push your latest `manifest.json` to Holistics. Once done, the dbt's metadata in Holistics is automatically updated.
We recommend that you set up a re-sync strategy from your side so the file `manifest.json` is automatically pushed to Holistics on a regular (or trigger) basis.
### I am using dbt Cloud, how can I integrate with Holistics?
Check out [this guide](cloud) to integrate dbt Cloud with Holistics.
### Why my metadata from dbt is not populated in Holistics, although I have set up dbt integration successfully
Sometimes, when you have done setting up the dbt integration, your dbt descriptions and lineage diagram are not populated in the related Holistics model.
This is probably because your **targeted schema** when executing dbt run is different from the schema of the Holistics Model.
This issue happens quite frequently when you have 2 tables with the same name but in different schema in your Database (for e.g, `prod.users` table and `dev.users` table). Then when creating Holistics, you have accidentally selected the users table in `dev` schema, while your dbt model has targeted to `prod` schema.
Since we detect the metadata from dbt by mapping our models with dbt models via schema and database name, you would need to ensure that the schema (table name) and database name match between dbt model and Holistics model.
---
## dbt integration
## Introduction
dbt is a popular data transformation tool that data teams use to pre-transform data inside data warehouses before pushing it to the BI layer. However, a common problem data teams face is: **dbt and BI tools don't talk to each other**. This leads to problems like:
1. **Disconnected metadata at BI layer:** The table fields’ descriptions defined in dbt are not exposed to business users in the BI interface.
2. **Discontinuous data flow trigger** (Stale data in BI reports when data refreshes)**:** When underlying data tables get rebuilt by dbt, the BI reports might still be using the cached, stale version.
Holistics supports "dbt integration" that solves the above 2 problems.
## How it works
This is a high-level description of the integration's mechanism:
- dbt is run and generates a `manifest.json` file that contains metadata related to the dbt project.
- Holistics will use the information in the `manifest.json` file to link the metadata to the corresponding Holistics data models with the same underlying data tables generated by dbt.
Some of the metadata from dbt that can be presented in Holistics includes table description, column description, and dbt's lineage diagrams.
## Use Cases & Benefits
- **Metadata sync:** When you change metadata in dbt model (e.g. descriptions of columns), you can push that metadata to Holistics to display at the BI layer.
- **Exposing dbt metadata to business users:** Business users can get access to schema metadata that data teams define in dbt docs.
- **Single code repository for analytics logic:** You can maintain a single GitHub repository with both the Transformation layer (dbt) and BI layer (Holistics AML).
- **Continuous flow trigger:** When dbt runs and underlying table data is updated, the trigger will inform Holistics. Holistics can refresh data in relevant reports using that model.
:::info
Currently, Holistics dbt integration only supports "connected metadata" functionality. The "data flow trigger" has not been supported.
:::
## Getting started
Holistics supports two ways to integrate with dbt. These are:
- [dbt Cloud Integration](/docs/dbt-integration/cloud)
- [dbt Core Integration](/docs/dbt-integration/core)
## FAQs
### If I update my model's metadata in Holistics, will it propagate back to dbt docs?
No. The direction is only one way (from dbt docs → Holistics).
### What is the relationship between the dbt model and Holistics' table model for the same underlying database table?
No direct relationship. Remember that there are 2 different concepts:
- dbt model: A SQL query that gets persisted into a database table.
- Holistics' Table Model: An abstraction on top of an existing database table.
---
## Modeling in dbt vs modeling in Holistics
:::info Question
“I’m currently doing my modeling in dbt. I see that Holistics also has a modeling layer. **When should I model my data in dbt vs when should I do it in Holistics?**”
:::
## **What is dbt, what is Holistics?**
dbt lets you write transformation code in SQL to turn raw data into transformed data that are ready for downstream consumption. Holistics occupies a different place - the BI layer, which helps organizations answer business questions and arrive at insights.
However, both dbt and Holistics support modeling. So what is the difference between them?
## What is Modeling?
First, what is modeling? Modeling is ultimately about encoding business logic and context into data.
There are two important steps in Modeling:
- **ETL Modeling:** Representing the core business elements and logic within raw data, translating them into structured data formats.
- **Semantics Modeling**: Encoding contextual business elements and logics as relationships and formulas, serving them as user-facing datasets that can be self-serviced.
So back to the topic at hand: modeling in dbt vs modeling in Holistics:
- **dbt is great at transforming raw data into cleaned and structured data.** dbt lets you write transformation code to ensure that data represents accurate business entities. It helps data teams kickstart the modeling process and produces outputs for BI tools to make use of.
- **Holistics is great at embedding contextual business elements and logic into self-service datasets.** Holistics lets you define dynamic relationships between business entities and quickly create additional business formulas as needed. It helps data teams take what dbt produces and completes the modeling process, enabling companies to make data-driven decisions.
When should you model in dbt versus in Holistics? It’s not a this versus that situation. You should use dbt and Holistics together to do modeling: use dbt to clean and provide structures to data, then use Holistics to define relationships and business logic within data.
## TLDR: When to model in Holistics and when to model in dbt?
You should use dbt to transform raw data into structured data and use Holistics to turn these structured data into models that are meaningful to your business.
Scenario
When to Model in dbt
When to Model in Holistics
Transforming raw data into cleaned data
dbt excels at transforming raw data into clean, structured data by writing SQL transformations to handle data type casting, filtering, and standardization.
Holistics does offer features to transform and clean data. But it's not as powerful as dbt.
Creating fact and dimension tables
dbt is great for transforming data into fact and dimension tables, providing a solid foundation for further analysis.
Holistics does offer features to transform data into fact and dimension tables. But it's not as powerful as dbt.
Calculating simple metrics dynamically
dbt can calculate simple metrics, but it has limitations: metrics are tied to specific time grains and require separate models for each combination of metrics/dimensions.
Holistics is better for calculating metrics dynamically without being tied to specific time grains, making it more flexible and easier to manage.
Calculating complex metrics (e.g., cohort analysis)
dbt struggles with complex metrics due to the need for intricate SQL logic, rigid time grain adjustments, and rewriting SQL for new dimensions.
Holistics excels in handling complex metrics with its high-level, declarative AQL syntax, allowing for dynamic adjustments and reusable metric definitions.
## A Practical Example
Let's consider a hypothetical e-commerce company and see how dbt handles increasingly complex analytics scenarios.
### Transforming raw data into cleaned data - dbt excels here
Your business allows consumers to place orders over an e-commerce website. The raw data contain all the necessary information about the orders but with inconsistent formats and unstandardized values. You want your data to represent e-commerce orders that include an Order ID, who made the order, on what date, and whether the order has been completed successfully or not.
```sql
-- models/raw_orders.sql
SELECT
id as order_id,
user_id as customer_id,
CAST(created_at AS DATE) as order_date,
status,
CAST(total_amount AS DECIMAL(10,2)) as order_amount
FROM {{ source('raw_data', 'orders') }}
WHERE status != 'test'
-- models/cleaned_orders.sql
SELECT
order_id,
customer_id,
order_date,
CASE
WHEN status IN ('completed', 'shipped') THEN 'successful'
WHEN status IN ('cancelled', 'refunded') THEN 'unsuccessful'
ELSE 'pending'
END as order_status,
order_amount
FROM {{ ref('raw_orders') }}
```
Here, dbt effectively cleans and structurizes the raw order data, handling data type casting, filtering out test orders, and standardizing order statuses.
### Giving factual and dimensional structures to data - dbt still performs pretty well
After cleaning data, you want to transform them into dimensions and facts so that you can separate between business entities (a product) and business events (a transaction).
```sql
-- models/dim_customers.sql
SELECT
customer_id,
first_name,
last_name,
email,
DATE(created_at) as registration_date
FROM {{ source('raw_data', 'users') }}
-- models/fact_orders.sql
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.order_status,
o.order_amount,
p.product_id,
p.quantity,
p.price as unit_price,
p.quantity * p.price as line_item_total
FROM {{ ref('cleaned_orders') }} o
JOIN {{ source('raw_data', 'order_products') }} p
ON o.order_id = p.order_id
```
dbt is still pretty great for transforming data into dimension and fact tables that can be easily joined for analysis.
### Calculating (fairly simple) metrics dynamically - dbt starts to show limitations
Can we write transformation code in dbt to calculate metrics? Definitely. Let’s see what happens when we use dbt to handle this task:
```sql
-- models/daily_order_metrics.sql
SELECT
order_date,
COUNT(DISTINCT order_id) as total_orders,
COUNT(DISTINCT customer_id) as unique_customers,
SUM(order_amount) as total_revenue,
AVG(order_amount) as average_order_value
FROM {{ ref('fact_orders') }}
GROUP BY order_date
-- models/product_revenue.sql
SELECT
p.product_category,
SUM(o.order_amount) AS total_revenue,
SUM(o.order_amount) / COUNT(DISTINCT o.order_id) AS average_order_value
FROM {{ ref('fact_orders') }} o
JOIN {{ ref('dim_products') }} p ON o.product_id = p.product_id
GROUP BY p.product_category
-- models/customer_revenue.sql
SELECT
c.customer_segment,
SUM(o.order_amount) AS total_revenue,
SUM(o.order_amount) / COUNT(DISTINCT o.order_id) AS average_order_value
FROM {{ ref('fact_orders') }} o
JOIN {{ ref('dim_customers') }} c ON o.customer_id = c.customer_id
GROUP BY c.customer_segment
```
While dbt can calculate these metrics, we're starting to see some issues:
- **Metrics are tied to specific time grains (daily, monthly)**. What if you want to calculate weekly order metric, quarterly order metric and annual order metric? You’d have to write separate transformations to produce these metrics. You cannot dynamically change between time grains without pre-aggregating them at all these levels.
- **Each new combination of metrics and dimensions requires a new model**. You need a separate model for each dimension you want to analyze (product, customer segment, region). If you want to analyze revenue by a new dimension, you need to create a new model. Combining dimensions (e.g., revenue by product and region) would require yet another model. This approach leads to a proliferation of models, making maintenance challenging (did you know that [thousands of models are the norms for dbt user](https://www.reddit.com/r/dataengineering/comments/175me07/how_many_models_is_too_many_models_dbt_horror/)s).
- **Reusing metric definitions across models is difficult**. If you need to change the definition of a metric (e.g., exclude certain types of orders from revenue calculation), you need to update it in multiple places. This repetition can lead to inconsistencies if updates are not applied uniformly across all models. There's no central place to manage and version these metric definitions.
### Calculating complex metrics - dbt clearly struggles
Let's stretch dbt further and use it to calculate cohort retention - a reasonably common metric that companies tend to care about:
```sql
-- models/cohort_retention.sql
WITH cohort_orders AS (
SELECT
customer_id,
DATE_TRUNC('month', MIN(order_date) OVER (PARTITION BY customer_id)) as cohort_month,
DATE_TRUNC('month', order_date) as order_month
FROM {{ ref('fact_orders') }}
),
cohort_size AS (
SELECT cohort_month, COUNT(DISTINCT customer_id) as cohort_customers
FROM cohort_orders
GROUP BY cohort_month
),
cohort_retention AS (
SELECT
cohort_month,
order_month,
COUNT(DISTINCT customer_id) as retained_customers
FROM cohort_orders
GROUP BY cohort_month, order_month
)
SELECT
cr.cohort_month,
cr.order_month,
cs.cohort_customers,
cr.retained_customers,
cr.retained_customers::FLOAT / cs.cohort_customers as retention_rate
FROM cohort_retention cr
JOIN cohort_size cs ON cr.cohort_month = cs.cohort_month
ORDER BY cr.cohort_month, cr.order_month
```
Here, we see several issues:
- **Metrics are not easily reusable or adaptable to different time grains or dimensions**. The cohort analysis above is based on a monthly time grain. If we wanted to adjust this to weekly or daily cohorts, we would need to write new SQL transformations or heavily modify the existing logic. This rigidity makes it difficult to dynamically explore different time grains without significant effort.
- **Adding new dimensions requires rewriting SQL**. If we want to perform cohort analysis by a different dimension (e.g., region or product category), we would have to modify the SQL significantly. This lack of flexibility makes it hard to iterate and experiment with different analyses. The SQL queries become highly coupled to specific data structures, and the effort to refactor them grows exponentially with each added dimension.
- **Changes in metric definitions lead to multiple updates across models**. If the definition of cohort retention changes (e.g., including only certain types of customers or orders), the SQL must be updated in multiple places. This requirement for repeated updates introduces the risk of inconsistencies and bugs, as changes might be applied incorrectly or overlooked in some models.
While dbt can technically handle complex metric calculations, it is not well-suited for calculating complex metrics that are used in different situations. The limitations in handling complex metrics stem from its reliance on static SQL transformation code, which lacks the flexibility, manageability, and adaptability needed for more sophisticated analytical use cases.
## How would Holistics handle Complex Metrics Modeling?
We have talked about how dbt is not the right tool for calculating complex metrics. So what’s the alternative? At Holistics, we believe we have the answer to this problem.
Here is how you would use Holistics to perform Cohort Retention Analysis
VIDEO
Here’s the code setup:
```sql
Dataset e_commerce {
// Define a dimension that represents when each user makes their order
dimension acquisition_month_cohort {
model: users
type: 'date'
label: 'Acquisition Month Cohort'
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
// Calculate the Total Users metric
metric total_users {
label: "Total Users"
type: "number"
definition: @aql count(users.id);;
}
// Define a dimension that shows the month number
dimension month_no {
model: orders
label: 'Month Number'
type: 'number'
definition: @aql date_diff('month', orders.cohort_month, orders.created_at | month());;
}
// Define retention: how many users of the cohort are still active in consecutive months
metric retention {
label: 'Retention'
type: 'number'
definition: @aql (total_users*1.0) / (total_users | of_all(orders.month_no));;
}
}
```
What are the advantages compared to dbt when using Holistics to perform complex modeling?
**Dynamic Metric Definitions without Complex SQL.** Unlike dbt, which requires writing intricate SQL for complex metrics, Holistics comes with Analytics Querying Language (AQL short) that allows users to define metrics and dimensions using a high-level, declarative syntax. This approach abstracts away the complexity of the underlying SQL, allowing users to focus on business logic rather than technical implementation details. For example, the definition of the `acquisition_month_cohort` dimension in AQL uses a simple expression to compute the cohort month based on the user acquisition date.
```sql
dimension acquisition_month_cohort {
model: users
type: 'date'
label: 'Acquisition Month Cohort'
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
```
This code snippet defines a dimension dynamically without needing subqueries or window functions, simplifying the process of calculating cohort metrics.
**Adaptability to Different Time Grains and Dimensions**: AQL makes it easy to adapt metrics to different time grains and dimensions. For instance, the `month_no` dimension is defined to calculate the difference in months between a user’s cohort month and the order date.
```sql
dimension month_no {
model: orders
label: 'Month Number'
type: 'number'
definition: @aql date_diff('month', orders.cohort_month, orders.created_at | month());;
}
```
This allows analysts to dynamically adjust the cohort analysis to any time grain (e.g., daily, weekly) by changing the function or parameters in a centralized definition. There is no need to rewrite complex SQL queries for each different grain or dimension, as the dimensions and metrics are centrally defined and managed
**Reusable Metric Definitions across Different Contexts**:
AQL facilitates the reuse of metric definitions across different contexts without requiring redundant code. For example, the `total_users` metric is defined once:
```sql
metric total_users {
label: "Total Users"
type: "number"
definition: @aql count(users.id);;
}
```
This definition can be reused in multiple analyses (e.g., retention, engagement, LTV) without duplicating the logic. If the definition of "Total Users" needs to change (e.g., to exclude certain types of users), it is updated in one place, and all dependent metrics are automatically updated. This prevents the proliferation of model definitions and reduces the maintenance burden.
---
## Export Reports to Azure Blob
:::warning DEPRECATED
This feature is deprecated. If you would like to use it, please make a feature request on [Holistics Community](https://community.holistics.io/c/feature-suggestions/13) or to support@holistics.io.
:::
Using Azure Storage schedules to send your report to your **Azure Blob Storage**.
**Some common use cases:**
- Backup report data periodically to an Azure Blob Storage server
- Deliver data regularly to your partners' servers
## Data Freshness
Azure Blob Schedule always uses **updated data** from the user's database. It **does not** fetch data from our cache.
## How to Setup
This section describes how you can set up Holistics to export report data to your Azure Blob Storage schedule.
## Configuring Azure Blob Integration
To manage your **Azure Blob Storage** connections, please visit [Integrations Page](https://secure.holistics.io/manage/integrations)
To add a new Azure Blob Storage connection, click **Add Azure Blob Storage** and fill in the required information below
- **Display name**: Add integration title to manage your different connections
- **Storage Name**: Your storage account name
- **Storage SAS**: Token: Shared Access Signature token to access your storage. See below for more information about the permission.
- **Container**: All your files will be stored in this container
### Azure Blob Storage User Permissions
We need at least the CREATE/WRITE blob permission.
If possible, please permit us to delete the blob to delete the temporary file created by Holistics for the connection test.
## Setup an Azure Blob Schedule
In any report, click **Schedules** on the toolbar, then choose **New Azure Blob Schedules**
Then fill in the required information below for your scheduled export
- **Azure Blob Connection**: Choose the connection you want to create an export schedule, you can manage it at [Integrations Page](https://secure.holistics.io/manage/integrations)
- **File path**: The path of the destination file. Holistics will create folders/files if they do not exist. Otherwise, that file will be overwritten (CSV/Excel files are supported)
- **Schedule**: Monthly/Weekly/Daily/Hourly...
- **Filter Values**: Set the filter values for the report
## Writing Sequential Files
Sometimes when writing to the destination, you want the system to add new files (based on date/timestamp) instead of overwriting existing files.
```
{{$today}}/{{$source_title}}_{{$timestamp}}.xlsx
```
The above will produce filenames like:
- `2017-05-06/sales_pivo_t_table_1494106200.xlsx`
- `2017-05-07/sales_pivo_t_table_1614106200.xlsx`
The variables you can use are:
- `$today`, `$yesterday`: Today or yesterday in YYYY-MM-DD
- `$timestamp`: Unix timestamp
- `$source_title`: Title of the report, in lowercase and has its special characters
replaced by underscores. E.g. 'Sales@Pivo^*?t Table' -> 'sales_pivo_t_table'
## Azure Blob Schedule Execution Flow
An **Azure Blob** Schedule execution follows these steps:
1. Execute the Query Report to get the result data
2. Move the file (storing the result data) to the destination path of the container.
---
## Data Alerts(Delivery)
## Introduction
Data Alerts allow users to receive automatic notifications when the data meets certain conditions, so they can make timely and strategic decisions for their business. You can also send Data Alerts to a webhook endpoint, turning a dashboard condition into an event that triggers work in another system.
:::tip Use alerts as integration triggers
Choose **Webhook** as the alert destination when you want Holistics to push matched data into a CRM, marketing automation tool, support platform, or custom workflow. For example, you can monitor a segment such as members who have not visited in 90 days, then trigger a re-engagement campaign the moment the condition is met.
:::
## How Data Alert works
### High level
Holistics Data Alert will be applied on each widget. Holistics will check the widget results at a user-specified frequency.
Then, if the results match a certain set of conditions, our system will send an alert to users via the selected channel.
### Details
Holistics Data Alert comprises three main components:
#### 1. Alert Conditions
Alert Conditions are the criteria that the data must meet in order for the alert to be triggered. It contains a field (including **Business Calculations fields**), an operator, and a value.
The fields that you can select are limited to fields used in the widget's settings.
> If a field is removed from the widget settings, it will also be removed from the Alert Condition and displayed as an "Invalid field". The alert will still run, but it will fail and will notify you via email.
Note: When the alert runs, it will scan the complete explore result, and then send an alert if any record in the result matches the conditions
#### 2. Alert Frequency
Alert Frequency specifies how often Holistics checks if the widget results meet the Alert Conditions.
For example, you have a widget for "App's Daily Active Users". You want to be notified when the number of daily users passes 1000.
If you set the Alert Frequency to be Daily at 8:00 AM, Holistics will start checking the data at 8:00 AM every day to see if it is over 1000 or not.
#### 3. Alert Destination
Alert Destination is the channel where you receive your alerts. When you create an alert, you pick the channel that fits how your team works. Holistics supports three delivery channels:
- **Email**: Send alerts to one or more email addresses.
- **Slack**: Post alerts to a Slack channel (pick it from the list, or enter the channel ID manually if it isn't shown).
- **Webhook**: Send alerts to a webhook endpoint so you can integrate with third-party applications and services. Use this when the alert should trigger an action outside Holistics, such as updating a CRM segment, sending leads to a marketing automation flow, opening a support ticket, or calling an internal API.
You choose the channel at the top of the Data Alert dialog, and the form swaps in the field that channel needs.
For more on configuring webhook endpoints, see the [Webhook documentation](/docs/delivery/webhook).
## Example: Trigger re-engagement from a data segment
Webhook alerts are useful when your data condition is the start of an operational workflow, not just something a person needs to read. Instead of exporting a list on a schedule, Holistics can evaluate the latest widget result and send the matching records to another system when the condition is met.
For example, a customer success or marketing team could create a report of members who have not visited in the last 90 days, then create a Data Alert with these settings:
1. Set the alert condition to match members whose `days_since_last_visit` is greater than or equal to `90`.
2. Select **Webhook** as the destination.
3. Enter the webhook endpoint from your CRM, marketing automation platform, Zapier, Make, or your own integration service.
4. Choose the check frequency that fits the campaign, such as daily at 8:00 AM.
When Holistics finds matching records, it sends the alert payload to the webhook endpoint. The downstream system can then add those members to a re-engagement audience, start an email journey, create a task for an account owner, or trigger any other workflow your team has configured.
## Set up Data Alerts
To start creating an alert, select **Data Alert** from the menu icon of the report > click **Data Alert > Create new alert**:
Then fill in the following sections:
- **Title:** Provide a name for your alert
- **Destination:** Select the channel you want to send the alert to (Email, Slack, or Webhook).
- **Recipients / Webhook Endpoint**: For Email, enter the email addresses who will receive the alert, separated by commas. For Webhook, enter the HTTPS endpoint that should receive the alert payload.
- **Alert Conditions:** Select the condition that you want your data to meet.
- **Check Frequency:** Specify how often you want Holistics to check if your data meets the condition set in Alert Conditions.
- **Controls** (optional): Customize dashboard controls before sending alerts. You can adjust [Filters](/docs/filters/), [Period Comparison](/docs/period-comparison), and [Date Drill](/docs/interactions/date-drills.md).
- Changes made here **will not** affect the main dashboard.
- You can only modify **existing** controls. New controls **cannot** be created.
- The selected controls will be applied to the widget result **before** Holistics checks whether it meets the Alert Conditions.
- **Message:** You can customize the alert message using HTML (for Email) or [Slack markup](https://api.slack.com/reference/surfaces/formatting) (for Slack) and dynamic variables like `{{$dashboard_title}}`, `{{$today}}`, and more. See [Dynamic Variable Support](/docs/delivery/data-alert#data-alert-dynamic-variable-support) for details.
- **Failure Recipient Email** (optional): A comma-separated list of email addresses to receive notifications when the alert fails to deliver, in addition to the alert creator and the dashboard/report creator. Learn more in [Notify Delivery Failures](/docs/delivery/notify-failed-deliveries).
## Manage Data Alerts
There are two ways to manage the alerts you have created.
- To manage all the alerts of a report, click on the menu icon "..." in the top right of a report, and select Data Alert.
At the Data Alert modal, you can view basic information about an alert: Title, Frequency, and Destination. Clicking on the menu icon "..." will give you 3 options: Edit, pause, or delete an alert.
- To manage all the alerts you have created, click on Tools > Data Alert. Here, you can filter alerts by their channel: Email, Slack, or Webhook.
## Data Alert: Dynamic Variable Support
You can use the following variables to personalize your Email and Slack alert messages:
| Variables | Description | Email Subject | Email Body | Slack |
| --- | --- | --- | --- | --- |
| `{{$matched_conditions}}` | List of alert conditions | ❌ Not available | ✅ | ✅ |
| `{{$matched_data}}` | Table of met records | ❌ | ✅ | ❌ |
| `{{$dashboard_title}}` | Insert the dashboard title | ✅ Available | ✅ | ✅ |
| `{{$dashboard_url}}` | Include a link to the dashboard (accessible only to users with the right permissions) | ❌ | ✅ | ✅ |
| `{{$dashboard_controls}}` | List the applied dashboard controls | ❌ | ✅ | ✅ |
| `{{$report_label}}` | Insert the report/widget title | ✅ | ✅ | ✅ |
| `{{$report_url}}` | Include a link to the widget (accessible only to users with the right permissions) | ❌ | ✅ | ✅ |
| `{{$today}}` | Display the date the schedule is sent. Example: Sales report for `{{$today}}` → Sales report for 2024-02-18 Tue. | ✅ | ✅ | ✅ |
| `{{$yesterday}}` | Display the previous day's date | ✅ | ✅ | ✅ |
| `{{$this_month}}` | Display the current month | ✅ | ✅ | ✅ |
| `{{$last_month}}` | Display the previous month | ✅ | ✅ | ✅ |
## To be supported
Attachment files with format: PDF, PNG, CSV, Excel.
## FAQ
#### 1. Which role can add or edit a Data Alert?
Users with Admin and Analyst roles can add or edit any Data Alerts.
#### 2. Can I edit/remove the Data Alert created by another person?
Yes, if your role in Holistics is Admin or Analyst, you can do that.
#### 3. When to use Dashboard filters?
It's best to use Dashboard filters when you want to limit the widget results before Holistics checks if that result meets the Alert condition.
Also, you might consider using Dashboard filters as an alternative to Alert Conditions when the field you want to set as Alert Conditions does not exist in the widget settings.
---
## Email Schedules
Email Schedules allow admins/analysts to schedule a report/dashboard to be sent to a group of recipients via email. The feature can apply to multiple use cases such as:
* **Daily summary metrics** email to send to the management team every morning.
* **Weekly export of data via email to multiple external partners** outside of your company (with each partner only seeing their data).
* **Email alert** so you get an email notification when something wrong happens.
## Data Freshness
Check out [Data Freshness](/docs/delivery/export-data#data-freshness) for more details.
## Delivery Timing
### Email Delivery
* Email schedules may not arrive at the exact scheduled timing due to query execution time, but we use a reliable third-party email service to deliver email. If for some reason when sending the email we cannot reach the 3rd-party service, we retry up to 3 times before raising an error.
### Monthly Reports
* If you plan to send monthly reports at the end of each month (i.e. 30th or 31st of every month), the email schedule will not trigger on months without that date (e.g. email scheduled for the 31st of each month will never send in February)
* We instead recommend that you send such reports on the 1st of each month and set the date filter to `last month begin to last month end`.
## Set up Email Schedules
To set an email schedule for a dashboard, in the dashboard view page, click on **Export > Send to Email:**
You'll see the following form:
- **Title:** Provide a name for your schedule
- **Recipients**: Enter a list of email addresses (separated by commas).
- **Controls (optional)**: Customize dashboard controls before sending each email. You can adjust [Filters](/docs/filters/), [Period Comparison](/docs/period-comparison), and [Date Drill](/docs/interactions/date-drills.md).
- Changes made here **will not** affect the main dashboard.
- You can only modify **existing** controls. New controls **cannot** be created.
- **Exported options**: Choose to export the entire dashboard, individual tabs (if the dashboard has multiple tabs), or specific widgets.
- **Frequency**: Set the schedule interval (e.g., every X hours, daily, weekly, monthly, etc.).
- **Attachments (optional)**: Attach dashboards as PNG, PDF, CSV, Excel (.xlsx), or Inline widgets.
- **Email Content**: You can customize the **Email Subject** and **Email Body** using HTML and dynamic variables like `{{$dashboard_title}}`, `{{$today}}`, and more. See [Dynamic Variable Support](/docs/delivery/email-schedules#data-schedule-dynamic-variables-support) for details.
- **Failure Recipient Email (optional)**: A comma-separated list of email addresses to receive notifications when the email schedule fails to deliver, in addition to the schedule creator and the report/dashboard creator. Learn more in [Notify Delivery Failures](/docs/delivery/notify-failed-deliveries).
## Manage Email Schedules
You can manage email schedules in two ways:
### 1. From an Individual Dashboard
- Open the dashboard and go to **Export > Manage Schedules** to view all export schedules.

- Alternatively, navigate to **Settings > Data Schedules**:

Here you can:
- Click **Send All** to immediately send all scheduled emails.
- Click **New Schedule** to create a new email schedule.
### 2. From the Global Schedules Management Page
Click on the **Tools** icon (🔧) > **Schedules** > **Email Schedules** tab:

From here, you can:
- Click the **More Options** (`⋮`) icon next to an email schedule and select Send/Edit/Delete to manage this schedule.
- Go to **Job History** to view past email deliveries.
## Set up the Sender Name
We allow customizing the sender name of these scheduled emails. You can configure it in your workspace's **Admin Settings → Data Delivery Settings**.
## Data Schedule: Dynamic Variables Support
You can use the following variables to personalize your scheduled Email and Slack messages:
| Variables | Description | Email Subject | Email Body | Slack |
| --- | --- | --- | --- | --- |
| `{{$dashboard_title}}` | Insert the dashboard title | ✅ Available | ✅ | ✅ |
| `{{$dashboard_url}}` | Include a link to the dashboard (accessible only to users with the right permissions) | ❌ Not available | ✅ | ✅ |
| `{{$dashboard_controls}}` | List the applied dashboard controls | ❌ | ✅ | ✅ |
| `{{$today}}` | Display the date the schedule is sent. Example: Sales report for `{{$today}}` → Sales report for 2024-02-18 Tue. | ✅ | ✅ | ✅ |
| `{{$yesterday}}` | Display the previous day's date | ✅ | ✅ | ✅ |
| `{{$this_month}}` | Display the current month | ✅ | ✅ | ✅ |
| `{{$last_month}}` | Display the previous month | ✅ | ✅ | ✅ |
---
## Export & Sharing Data
Holistics offers multiple ways to export and share dashboard data to both internal and external users.
## Export Data
### Export to a Local File
You can export data from a dashboard in two ways: exporting the entire dashboard, or exporting a single widget.
**Dashboard export**
Use the **Export** button at the top right of the dashboard to save the entire dashboard as a **PDF, PNG, or Excel** file. Dashboard-level interactions (dashboard filters, date drill-down controls) are reflected in the export.
**Widget export**
Click a widget's context menu and select **Export** to export that individual chart. Available formats:
- **Data export:** PDF, Excel, CSV (Raw data), CSV (Formatted data)
- **Image export:** PNG, JPEG, SVG
Widget exports reflect any ad-hoc interactions you have applied to that widget:
- **Block-level filters** (include/exclude values, Top N/Bottom N) - export shows only the filtered values
- **Date drill-down** - export captures the zoomed-in time period, not the full default date range
- **Breakdown changes** - export reflects the modified grouping dimension
What you see on screen is what you get in the export. No extra steps needed.
:::info Note
Ad-hoc widget interactions are reflected in **widget exports** only, not in dashboard-level exports.
:::
### Export to Email / Slack / Google Sheets / SFTP / Telegram
Deliver dashboards to colleagues or clients through various channels, either **on-demand** or **on a recurring schedule**:
- [Send to Email](/docs/delivery/email-schedules)
- [Send to Slack](/docs/delivery/slack-schedules)
- [Send to Google Sheets](/docs/delivery/google-sheets)
- [Send to SFTP](/docs/delivery/sftp-schedules)
- [Send to Telegram](/docs/delivery/telegram-schedules)
:::info Note
You can schedule an export only if you have a default role of **Admin** or **Analyst**. For more information about default roles, see [Permission System](/docs/admin/permission-system).
:::
### Data Freshness
- **On-demand exports**: Use **cached** dashboard data when available for faster performance.
- **Scheduled deliveries**: Use **fresh data** from your database, bypassing cache to ensure the most up-to-date information.
### Disable Data Download
For enhanced data security, you can restrict users from downloading data. This feature is available in our **Security Compliance Suite (SCS)** and **Enterprise** plans.
Contact us at [support@holistics.io](mailto:support@holistics.io) to learn more.
### Host Exports on Your Own Amazon S3
By default, Holistics generates exported files (Excel/CSV) on our own AWS S3 storage, encrypts them, and removes them automatically after 24 hours. With this Enterprise feature, exported files are stored in **your own S3 bucket** instead. This is useful when your organization requires that all exported data remain in infrastructure you control.
:::info Enterprise feature
Available on the **Enterprise** plan. Ask your account admin to submit a support ticket in-app to enable.
:::
### Row Limits on Export Operations
See [Row limits](/docs/admin/row-limits) for the maximum number of rows you can view or export.
## Share Dashboard Access
Beyond exporting data, you can share dashboards directly with users via:
### User Access
Grant specific users or groups access to view or edit dashboards within Holistics. This allows them to directly interact with live data without the need to export.
Learn more: [Dashboard-level Permissions](/docs/admin/permission-system#dashboard-level-permission)
### Shareable Links
Generate links that allow recipients to view dashboards without logging into Holistics.
Learn more: [Shareable Links](/docs/delivery/shareable-links)
### Public Embedding (iFrame Embedding)
Embed dashboards into public-facing websites or applications for external audiences.
Learn more: [Public Embedding](/docs/delivery/public-embedding)
### Embedded Analytics
Integrate Holistics dashboards into your own applications for a white-labeled experience.
Learn more: [Embedded Analytics](/embedded)
---
---
## Export to Google Sheets
Google Sheets Schedules allows you toautomatically export data from a reports or dashboards directly to Google Sheets.
This is useful when you need to push raw data from your database into a spreadsheet for further analysis or calculations.
## Data Freshness
Check out [Data Freshness](/docs/delivery/export-data#data-freshness) for more details.
## Set up Google Sheets Schedules
To set a Google Sheets schedule for a dashboard, in the dashboard view page, click on **Export > Send to Google Sheets**:
You'll see the following form:
- **Title:** Provide a name for your schedule
- **Spreadsheet**: Select a spreadsheet to which you want to export data.
- **Controls** (optional): Customize dashboard controls before sending each export. You can adjust [Filters](/docs/filters/), [Period Comparison](/docs/period-comparison), and [Date Drill](/docs/interactions/date-drills.md).
- Changes made here **will not** affect the main dashboard.
- You can only modify **existing** controls. New controls **cannot** be created.
- **Exported widget**: Select the widget(s) and the worksheet page(s) that you want to export your widget to the spreadsheet. We support exporting multiple widgets in the same data delivery, and each widget must have its worksheet page
- **Frequency**: Set the schedule interval (e.g., every X hours, daily, weekly, monthly, etc.)
- **Failure Recipient Email** (optional): A comma-separated list of email addresses to receive notifications when the Google Sheets schedule fails to deliver, in addition to the schedule creator and the report/dashboard creator. Learn more in [Notify Delivery Failures](/docs/delivery/notify-failed-deliveries).
:::info Note
- For the **first time** you select a spreadsheet, the system will prompt you to authorize Holistics to get access to your Google Drive. The credential token is kept to keep the schedule running recurringly.
- When authorizing, make sure that the account you use in the login popup is the account that you logged into in the Google Chrome browser.
:::
## Caveats
- The maximum number of records that are pushed to Google Sheets is 15,000 per execution. This is a limit imposed to prevent timeout and over-exceeding quota on Google Sheets API.
- Google Sheets has a **50,000 character limit per cell**. If any cell in your data exceeds this limit, the export will fail with an "invalid request" error. Consider truncating long text columns in your query.
- At the moment, only the underlying data behind the charts/widgets will be exported. No charts or pivot tables will be exported.
## FAQs
#### Will the entire Google Spreadsheet document (with multiple sheets) be overridden?
No. Each schedule will export data from a single chart widget into a single sheet in your spreadsheet document. The other sheets will remain intact.
#### How can I export more than 15000 rows on Google Sheet Export?
- You can split your report result into smaller results using a filter. Then create multiple Google Sheet exports for each page.
- Otherwise, please contact us via [support@holistics.io](mailto:support@holistics.io) to raise the Google sheet export limit for your Holistics account.
Kindly specify the new limit you would like to have, and kindly note that higher limits might negatively affect the performance and reliability of the exporting. But we can still adjust later if there is any issue.
#### My Google Sheets export fails with "Invalid Request" error, what should I do?
This is often caused by a cell value exceeding Google Sheets’ **50,000 character limit per cell**. When any single cell in your exported data contains more than 50,000 characters, the entire export will fail with an “invalid request” error (even if the row count is well under the 15,000 row limit).
To fix this, identify the column with long text values and truncate it in your query. For example:
```sql
select left(long_text_column, 50000) as long_text_column
from your_table
```
Alternatively, you can split the long column into multiple columns or remove it from the export if it’s not essential.
For more details on Google Sheets limits, refer to [Google’s documentation](https://support.google.com/drive/answer/37603).
#### I face a “Google Authentication Permission Error”, what should I do?
If you encounter any permission issues (For example the `forbidden: the caller does not have permission` error which means you do not have permission to access the Google spreadsheet, `The API developer key is invalid` error or the OAuth flow fails with a `400 error` stating the request is malformed and shouldn't be retried), please simply open the **Edit** schedule dialog and click **refresh the token.**
:::info Note
- You only need to refresh the token one time for all schedules created by one account.
- Make sure that your browser is ***allowing third-party cookies***. If you are using Chrome, you should be able to find the settings under chrome://settings/cookies.
:::
#### Can other Holistics users use my Google authentication credentials?
We link the Google credential token only to your Holistics account and will not share the token with anyone. It means if your colleagues also have a Holistics account, they will not reuse your token but instead, they will need to go through the process of generating their own token.
---
## Notify Delivery Failures
If for some reason, data schedule or data alert fails to run and deliver, the system will notify the following users via email by default:
- The schedule/alert creator
- The dashboard/report creator
## Configure additional failure recipients
You can also configure **additional** recipients to receive failure notifications besides the default recipients.
### Per schedule
You can specify a **Failure Recipient Email** list for a specific schedule or alert, separated by commas.
### Global
If you want to add your Data team or other teams' mailing list to receive notifications for **all** delivery failures, you can set the **Email Failure Recipients List** option in your workspace's **Admin Settings → Data Delivery Settings**.
:::info Note
Email Schedule always runs a fresh query and uses updated data from the user's database (does not fetch cached data).
:::
:::tip
From our experience, you could use an email group (non-personal email) to receive those notification failures.
This not only prevents overwhelming a personal email, but also allows multiple people to receive the notifications (instead of a single person).
:::
---
## Public Embedding (iFrame Embedding)
## Introduction
**Public Embedding (iFrame Embedding)** allows you to integrate Holistics Dashboards into:
* Websites and blog posts
* Internal tools (e.g., Notion, Coda, wikis)
* Any platform supporting iframes
With a no-code/low-code setup, you can easily embed, edit, and refresh your dashboards. This feature ensures that both internal teams and external stakeholders always have access to the latest data, right where they need it.
## How it works
Under the hood, we make our [Sharable Link](/docs/delivery/shareable-links) embeddable on the web. To set up, simply follow these steps:
1. Go to your dashboard.
2. Click on `Share` > `Sharable Link`.
3. Create a new Sharable Link.
4. Embed this link in your application. In certain applications, you might need to put the link in an iframe.
Here's an example:
```html
```
Adjust the `width` and `height` attributes to suit your dashboard and layout needs.
## Considerations
**Public Embedding makes your dashboard viewable to anyone on the internet without authentication.**
Before publishing:
- Ensure you have permission to share the data publicly
- Review your organization's data-sharing policies
- Never publish confidential or proprietary information
:::tip
To securely embed your content in an internal portal or website, use our [Embedded Analytics](/embedded/). This option ensures that all permissions and data security are enforced when your users view your internal data.
:::
---
## Export Reports to SFTP
Use SFTP schedules to set up reports/dashboards to be sent to your SFTP folders every day. SFTP Schedules allow admins/analysts to schedule a report/dashboard to be sent to an SFTP destination.
**Some common use cases:**
* Backup report data periodically to an SFTP server
* Deliver data regularly to your partners' servers
## Data Freshness
Check out [Data Freshness](/docs/delivery/export-data#data-freshness) for more details.
## Set up SFTP Schedules
### Configure SFTP Connection
To manage your SFTP connections, go to Integrations Page:
To add a new SFTP connection, click **Add SFTP Connection** and fill in the required information below
- **Display Name**: Add integration title to manage your different connections
- **Host, Port**: Address of the SFTP server
- **Root Path**: Destination files will be preceded by this path
- **Username, Password**: Credentials to access the SFTP server
- **Holistics Public Key**: If you are using public key authentication instead of username/password, please add Holistics public key to your authorized keys
:::tip Best Practice
For security, restrict the SFTP user's permissions to only read and write files within a specific directory. We recommend creating a dedicated user for SFTP purposes rather than using an existing account. Check out [this guide](#set-up-a-dedicated-sftp-user) for step-by-step instructions
:::
### Set up scheduled export options
To set an SFTP schedule for a dashboard, in the dashboard view page, click on **Export > Send to SFTP**:
You'll see the following form:
- **Title:** Provide a name for your schedule
- **Controls (optional)**: Customize dashboard controls before sending each export. You can adjust [Filters](/docs/filters/), [Period Comparison](/docs/period-comparison), and [Date Drill](/docs/interactions/date-drills.md).
- Changes made here **will not** affect the main dashboard.
- You can only modify **existing** controls. New controls **cannot** be created.
- **Exported Widget**: Choose the widget you want to create Export Schedule
- **SFTP Connection**: Choose the connection you want to create the Export Schedule, you can manage it at [Integrations Page](https://secure.holistics.io/manage/integrations)
- **File Name**: The path of the destination file. Holistics will create folders/files if they do not exist. Otherwise, that file will be overwritten (CSV/Excel files are supported). You are able to include/exclude headers and choose the suitable separator.
- **Frequency**: Set the schedule interval (e.g., every X hours, daily, weekly, monthly, etc.)
- **Failure Recipient Email** (optional): A comma-separated list of email addresses to receive notifications when the SFTP schedule fails to deliver, in addition to the schedule creator and the report/dashboard creator. Learn more in [Notify Delivery Failures](/docs/delivery/notify-failed-deliveries).
## SFTP Schedule Execution Flow
An SFTP Schedule execution follows these steps:
1. Execute the Query Report to get the result data
2. Write the result data to a temporary file in `$REMOTE_PATH/.holistics_tmp/` (`$REMOTE_PATH` is configured in the SFTP connection)
3. Prepare the destination path: create the non-existing directories in the destination path
4. Move the temporary file to the destination path
## Export Sequential Files based on Timestamp
Sometimes when writing to the destination, you want the system to add new files (based on date/timestamp) instead of overwriting existing files.
```
{{$today}}/{{$source_title}}_{{$timestamp}}.xlsx
```
The above will produce filenames like:
* `2017-05-06/sales_pivo_t_table_1494106200.xlsx`
* `2017-05-07/sales_pivo_t_table_1614106200.xlsx`
The variables you can use are:
* `$today`, `$yesterday`: Today or yesterday in `YYYY-MM-DD` format
* `$today_underscore`, `$yesterday_underscore`: Today or Yesterday in `YYYY_MM_DD` format
* `$today_flatten`, `$yesterday_flatten`: Today or Yesterday in `YYYYMMDD` format
* `$timestamp`: Unix timestamp
* `$source_title`: Title of the report in lowercase with special characters replaced by underscores (e.g., 'Sales@Pivo^*?t Table' -> 'sales_pivo_t_table').
* `$source_title_uppercase`: Similiar to `$source_title` but in uppercase.
## Set up a dedicated SFTP User
This section will give you a step-by-step instruction on setting up a new user on your SFTP server that is dedicated to SFTP purposes. The new user will be restricted from accessing a specific directory and use SFTP commands only.
**Run the following commands as root.**
- Create a new user
```bash
adduser holistics_sftp
```
- Create a new directory. Later, we will make sure the SFTP user can only access this directory by configuring `ChrootDirectory`. It needs to be owned by the user `root` for the `ChrootDirectory` configuration to work
```bash
mkdir -p /holistics
chown root: /holistics
```
- Since `/holistics` is owned by `root`, new user `holistics_sftp` should not be allowed to write to that directory. Thus, we will create an inner directory that `holistics_sftp` can write to.
In this example, we are creating a new folder named `exported`. Later when you fill in the settings of the SFTP Connection in Holistics, you should input `exported` in Root Path, so that all output files will reside within this directory.
```bash
mkdir -p /holistics/exported
chown holistics_sftp: /holistics/exported
chmod 0755 /holistics/exported
```
Restrict the access of new users by editing `/etc/ssh/sshd_config`
```
# override default of no subsystems
#Subsystem sftp /usr/lib/openssh/sftp-server
Subsystem sftp internal-sftp
Match User holistics_sftp
X11Forwarding no
AllowTcpForwarding no
ChrootDirectory /holistics
ForceCommand internal-sftp
```
The above config makes sure user `holistics_sftp` can only read the directory `/holistics` and can only use SFTP commands.
Restart sshd service
```bash
service sshd restart
```
## FAQs
#### Could I use FTP instead of SFTP for scheduled deliveries?
No. To ensure your data security, Holistics only supports SFTP instead of FTP.
---
## Shareable Links
In Holistics, Shareable Links allow you to share dashboard access to **external users** with **strict access control measures** in place, so that they only see the data specifically prepared for them. Viewers also don't need a Holistics account to access the dashboard.
With Shareable Links, you can:
- Restrict the data (by particular conditions) for each link shared
- Set password protection for each link
## Create Shareable Links
Go to the dashboard and click on menu **Share → Shareable Link**
## Manage Shareable Links
To keep track of all shareable links, you can navigate to **Tools > Shareable Links** on the app header. You can quickly search, sort, and visit the reports and dashboards.
## Protect Shareable Links with Password
Since the link is publicly accessible, anyone with the link can access the underlying data, and this is not secure. To overcome this, Holistics allows you to set a password for the shareable link.
When a public user visits the password-protected link, he/she will have to enter the password to proceed.
### Enforce Password for all Shareable Links
:::info SCS plan only
This feature is exclusive to customers on the **Security Compliance Suite (SCS)** plan as part of info-security requirements. [View pricing plans](https://www.holistics.io/pricing/) for more details.
:::
If you want to make sure all of your Shareable Links are protected with passwords, contact Holistics at support@holistics.io to enable this feature.
:::info
Note that if this feature is active, permission to [enable Shareable Links](shareable-links#create-shareable-links) will be granted to only **Admin** accounts in your tenant.
Consequently, if you want both Admin and Analyst to be able to generate shareable links, you **will not be able to** enforce passwords for all created links.
:::
## Data Restriction with Shareable Links (Row-level Permission)
Shareable Links let you control what data viewers can see by applying **Permission Settings** to each link.
**Example**
You have a Revenue dashboard and want to share it with two external customers:
- Customer A should only see revenue from US
- Customer B should only see revenue from VN
**How to set up**
1. Create two separate Shareable Links (one for each customer: Link A and Link B).
2. For each link, define a permission condition in the Permission Settings section.
- Link A: `country.code = 'US'`
- Link B: `country.code = 'VN'`
3. Anyone opening a link will only see the data allowed by its permission condition.
## Default Filter Settings
Use **Default filter settings** in the Shareable Link settings to prefill filter values so viewers immediately see them when they first open the link. Prefilled values are only a starting point (viewers can change them as needed).
Note: Dashboard default filters don't sync to existing Shareable Links; edit the link's default filters to update them.
### Hide filters
You can hide filters from dashboard viewers by dragging them outside the canvas area in **Development mode**. When viewers access the dashboard through Shareable Links, these filters won't be visible.
:::danger This is not a data security feature
Hiding filters/controls is a visual feature only and does not restrict data access. Viewers can bypass hidden filters by modifying URL parameters or by inspecting the page and revealing hidden filters. For data security, we recommend using **[Row-level Permission](#data-restriction-with-shareable-links-row-level-permission)** instead.
:::
## FAQs
### Can I use Drill-through in Shareable Links?
Please note that [Drill-through feature](/docs/interactions/drill-through) is disabled with dashboards from Shareable Links.
---
## Export to Slack
## Introduction
Send scheduled reports and dashboards to your Slack workspace so your team stays in touch with the latest data and can start discussing questions instantly from Slack.
:::info Prerequisite
Your workspace must be connected to Slack first. See [Slack integration](/docs/integrations/slack#connect-holistics-to-slack) for the one-time setup.
:::
## Set up a Slack schedule
To set a Slack schedule for a dashboard, in the dashboard view page, click on **Export > Send to Slack**:
You'll see the following form:
- **Channels**: Choose the public channels you want to send to. For private channels, please manually type the channel name: e.g. `#top_secret`
- **Controls (optional)**: Customize dashboard controls before sending schedules. You can adjust [Filters](/docs/filters/), [Period Comparison](/docs/period-comparison), and [Date Drill](/docs/interactions/date-drills).
- Changes made here **will not** affect the main dashboard.
- You can only modify **existing** controls. New controls **cannot** be created.
- **Frequency**: Set the schedule interval (e.g., every X hours, daily, weekly, monthly, etc.).
- **Exported options**: Choose to export the entire dashboard or individual tabs (if the dashboard has multiple tabs).
- **Attachments** (optional): Attach dashboards as PNG or PDF formats.
- **Message content**: You can customize Slack message using [Slack markup](https://api.slack.com/reference/surfaces/formatting#basic-formatting) and dynamic variables like `{{$dashboard_title}}`, `{{$today}}`, and more. See [Dynamic Variable Support](/docs/delivery/email-schedules#data-schedule-dynamic-variables-support) for details.
- **Failure Recipient Email** (optional): A comma-separated list of email addresses to receive notifications when the Slack schedule fails to deliver, in addition to the schedule creator and the report/dashboard creator. Learn more in [Notify Delivery Failures](/docs/delivery/notify-failed-deliveries).
## Manage Slack schedules
Similar to Email, you can track all of your Slack Schedule from an individual dashboard, or from the global Schedules Management page. Refer to [this section](/docs/delivery/email-schedules#manage-email-schedules) for more details.
## Related
- [Data freshness](/docs/delivery/export-data#data-freshness): how Holistics handles freshness for scheduled exports.
- [Report storage and authorizations](/docs/integrations/slack#permissions-and-data-handling): storage limits, link expiry, and managing Slack authorizations across your team.
---
## Telegram Schedules
If you want to automatically forward scheduled dashboards to a Telegram channel for real-time alerts, mobile notifications, and team collaboration, check out our guide below for a quick setup using email schedules and automation.
[Guide: Set up Telegram schedules in Holistics using Gmail and Google Apps Script](https://community.holistics.io/t/set-up-telegram-schedules-in-holistics-using-gmail-and-google-apps-script/2534)

---
## Webhook for Data Alerts
## Introduction
Webhook for Data Alerts lets Holistics send an HTTP POST request when a report or dashboard widget matches your alert conditions. This is useful when the alert should trigger an action in another system, such as adding customers to a CRM segment, starting a marketing automation journey, creating a support ticket, or calling an internal API.
For example, you can build a report of members who have not visited in 90 days, then use a webhook alert to send those matched members to a re-engagement workflow as soon as Holistics detects them.
## How to set up
To use Webhook, set up Webhook as the destination for your Data Alert.
**Webhook Endpoint** (required): Enter your webhook URL. The URL must use HTTPS. Holistics will send a POST request to this URL when your data matches the alert conditions.
## Payload
The webhook payload includes the alert metadata, the conditions that were matched, and the records that matched those conditions. Your downstream system can use these fields to decide what workflow to trigger.
In Holistics' webhooks, the payload consists of these key details:
- metadata (object)
- data_alert_id (string)
- data_alert_title (string)
- report_title (string)
- dashboard_url (string)
- matched_conditions (array): Describing triggering conditions. Each item will include:
- field_info: Details on field usage within conditions, including field path, dataset, and model info.
- condition: Specifies the current condition with operator, modifier, values, and options.
- aggregation: Indicates any aggregation applied to this field.
- transformation: Indicates any transformation applied to this field.
- matched_data: Contains data that matched alert conditions
- fields (array): Column or field names within matched data, serving as keys in each `matched_data.records` item.
- records (array): The matched data are presented as objects, each representing a distinct data row.
```json
{
"metadata": {
"data_alert_id": 211,
"data_alert_title": "Admin user alert",
"report_title": "Demo Alert",
"dashboard_url": "https://secure.holistics.io/dashboards/3-demo-alert"
},
"matched_conditions": [
{
"id": 214,
"field_path": {
"field_name": "name",
"joins_path": null,
"model_id": 1,
"data_set_id": null
},
"aggregation": null,
"transformation": null,
"condition": {
"operator": "is",
"modifier": null,
"values": ["Admin"],
"options": null
}
}
],
"matched_data": {
"fields": ["Id", "Email"],
"records": [
{
"Id": "4",
"Email": "admin@holistics.io"
}
]
}
}
```
## Security
To secure webhook data transmission, make sure to use HTTPS for the destination URL. This encrypts communication and protects sensitive information.
For Holistics event verification, please refer to this [documentation](/docs/connect/ip-whitelisting#ip-addresses-to-whitelist) for the list of authorized IP addresses. This ensures that only valid requests are processed, preventing potential security risks.
:::info
For instructions on setting up IP Whitelisting in Make (Integromat) and Zapier, please visit this [section](#how-to-set-up-ipwhitelist-in-zapier-and-make-integromat).
:::
### Holistics-signature (Coming Soon)
To ensure message integrity and security, both the webhook provider and client will perform signature verification:
- For Requests from Holistics:
- Generate a hash string from the API payload and timestamp
- Encrypt the hash string using the 'webhook secret'
- Upon Receipt on Your Server:
- Create a 'signature' by combining the API's request and your secret.
- Verify the 'Holistics-Signature' in the request's header against this computed 'signature.' If they match, the alert is confirmed to originate from Holistics, fortifying data security.
## FAQs
### Do you have any tutorials for setting up with third-party tools?
Currently, we support Zapier and Make (Integromat). Here are some resources to help you get started:
- [Webhooks by Zapier](https://zapier.com/apps/webhook/help)
- [Make (Integromat)'s webhooks](https://www.make.com/en/help/tools/webhooks)
### How to set up IPWhitelist in Zapier and Make (Integromat)?
For Zapier, you can use a JavaScript (JS) Code Block:
**Step 1**: Create a Catch Webhook step with a Catch Raw Hook event
**Step 2**: Create a [JS code step](https://help.zapier.com/hc/en-us/articles/8496310939021#2-set-up-your-code-step-0-1)
First, you retrieve the IP from `headers__http_x_real_ip` in the webhook HTTP request from the Catch Webhook step as inputData
Then, use the following code to check if the webhook provider's IP is whitelisted:
```javascript
const ip_whitelist = ;
const ip = inputData.ip;
const is_whitelisted_ip = ip_whitelist.includes(IP);
output = { accepted_ip: is_whitelisted_ip }
```
Step 3: Create a Filter step with the condition to continue if the IP is accepted
For Make (Integromat), set up IP Restriction in the Webhook step.
If the IP is restricted, Make Webhook will respond with an access denied error.
### What happens if my 'zap' (in Zapier) or 'scenario' (in Integromat) fails? Does Holistics track these failures?
Holistics handles 'zap' (Zapier) and 'scenario' (Integromat) failures differently:
- **Zapier**: Zapier returns a success status (200) even if the URL is incorrect. In such cases, Holistics may mark the job as failed and send notifications only in cases of internal or network errors.
- **Integromat**: Integromat requires the exact URL; otherwise, it responds with a 404 error. If the URL is incorrect, Holistics will still mark the job as failed.
To check for errors specific to your 'zap' or 'scenario', please review the logs within the respective tools.
---
## Development workspace
The **Development workspace** is where data builders author models, datasets, and dashboards as AML files. Open it via the **Development** button in the header.
The workspace surfaces a few standard panels:
- **Mode toggle**: switch between Production (read-only) and Development. See [Code Deployment](/docs/development/dev-prod-mode).
- **Branch selector**: view and create branches. See [Branch Management](/docs/git-version-control/branch-management).
- **Code action button**: context-aware action (Pull, Commit, Publish). See [PR Workflow](/docs/continuous-integration/pr-workflow-auto-deploy).
- **Project Explore**: navigation tree on the left, file content viewer on the right.
- **Project Settings**: link your project to an [external Git provider](/docs/git-version-control/external-git).
- **Source Control**: review changes, [restore versions](/docs/git-version-control/version-restore), [delete branches](/docs/git-version-control/branch-management#delete-branch).
The rest of this page covers the rules specific to writing files in the workspace.
## Supported files
Holistics objects live in plain-text files with a `..aml` extension:
| File name | Corresponding feature |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `model_name.model.aml` | [Data Model](/docs/data-model) |
| `dataset_name.dataset.aml` | [Dataset](/docs/datasets) |
| `dashboard_name.page.aml` | [Canvas Dashboard](/docs/dashboards/) |
| `relationships.aml` | [Model Relationships](/docs/relationships) |
| `schedules.aml` | [Query Model Persistence Scheduling](/docs/persistence#2-create-persistence-schedule) |
| `file_name.md` | Markdown contents (e.g. `README.md`) |
You can create files of any extension, but only those above have meaning to Holistics.
## File names
File names can be arbitrary. All of the following are valid:
```
orders_master.model.aml
orders master.model.aml
Orders@Master.model.aml
```
We recommend `snake_case`.
:::caution Allowed characters
- Any Unicode characters except: `NUL`, `\`, `/`, `:`, `*`, `?`, `"`, `<`, `>`, `|`
- No space at the start or end of the name
- No period (`.`) at the end of the name
:::
## Object names
Object names (models, datasets, etc.) are stricter than file names:
- Must not start with a number or special character (except `_`)
- Must not contain spaces or special characters
Valid examples:
```
order_master
OrderMaster
_order_master
business_metrics
```
We recommend `snake_case`.
## File names vs. object names
:::tip
- File and object names don't need to match, but matching them is recommended.
- Renaming an object after it's referenced downstream will break those references.
- File names can be changed freely as long as the object names inside don't change.
:::
A typical use of the file/object split is keeping similarly-shaped models in separate folders:
```
data_warehouse
├── region_asia
│ ├── orders.model.aml // contains model orders_asia
│ ├── products.model.aml // contains model products_asia
│ └── business_metrics.dataset.aml // contains dataset business_metrics_asia
|
└── region_europe
├── orders.model.aml // contains model orders_europe
├── products.model.aml // contains model products_europe
└── business_metrics.dataset.aml // contains dataset business_metrics_europe
```
Object names still need to be unique across the project, so use prefixes or suffixes to disambiguate.
## Visual vs Code mode
Each file can be viewed in two modes:
- **Code mode**: plain-text AML, edited directly.
- **Visual mode**: graphical representation of the file with limited inline edits.


The two modes write to the same underlying file. Behavior differs by file type. See the dedicated docs for each feature in the table above.
## Project structure
For folder layout conventions, see [Project Structure](/docs/development/project).
---
## Code Search
## Introduction
Code Search is a feature in the Development workspace that helps data builders search through their codebase efficiently. Whether you're trying to discover existing code patterns, track data lineage, or perform refactoring tasks, Code Search provides the tools you need.
## Use Cases
- **Code Discovery:**
* Search by description, label, and other criteria
* Filter result by type or other properties
- **Data Lineage Tracing:** Find every file where a dataset, model, metric,... is referred to
- **Refactoring:** Replace search results with another piece of code
## Full Code Search Tutorial
VIDEO
## Using Code Search
### Basic Search
1. Navigate to the Development workspace
2. Click on the Code Search icon or use the keyboard shortcut
3. Enter your search term
4. Results will appear in real-time, showing:
- File name
- Line numbers
- Code context
- Matching highlights
### Advanced Search Syntax
Use these filters to refine your search:
- **```type:model + {keyword}```**: Search for keyword only in model.aml files (same for dataset, dashboard)
- **```type:metric + {keyword}```**: Search for metrics where metric name contains keyword (same for dimension, measure)
- **```datasource:{keyword}```**: Search files where ```data_source_name``` attribute contains keyword
- **```owner:{keyword}```**: Search files where ```owner``` attribute contains keyword
### Performing Code Replace
1. Enter your search term
2. Click on the "Replace" option
3. Enter the replacement text
4. Preview the changes
5. Apply the replacement
## Tips and Tricks
- When in Development, use keyboard shortcuts `Ctrl/Cmd + Shift + F` to quickly navigate to the Code Search tab
- You can combine several filters while searching.
---
## Development Mode, Production Mode and Deployment (Publication)
## Introduction
In Holistics, **Development Mode** and **Production Modes** are two views of your analytics code base that you can toggle in the Development page.
## Development Mode
In Holistics, Development Mode serves as a sandbox environment for analysts and developers to iterate and refine their data models and datasets. In this mode, you can make modifications and improvements without affecting the Production environment until you push the changes to Production.
If you decide to integrate your project with a third-party Git hosting service such as GitHub or GitLab, you can work on your isolated branch in Development Mode without affecting other team members' work. This allows for collaboration and development of the project.
## Production Mode
In **Production Mode**, you have access to the finalized version of your data models and datasets that are ready for end-users to explore and build reports. It's important to note that Production Mode is **read-only**, meaning you cannot make any changes to project files in this mode. This ensures that any changes made to your project are properly tested and verified before they are released to your end-users.
## Publishing to Production (Go Live)
When you are happy with your changes in your development branch, click **Publish** on top top right corner of the page to bring your changes to the end-user.
Changes published to **Production** (master branch) are automatically applied to the Reports, Dashboards (in Reporting tab).
Some notes about Holistics publication:
- If you do not integrate your project with any 3rd-party git hosting service (GitHub, GitLab,...), other people who publish around the same time as you might not keep their changes. In other words, the last person to publish wins.
---
## Dynamic Data Sources
:::warning Model Persistence
Dynamic Data Source does not support Query Model Persistence. Please check your data model persistence before using this feature.
:::
## Use Case
In some scenario, you want to **point the dashboards to different data source (database or data warehouse) dynamically**, based on who's viewing the report or whether it's in production or dev mode.

Thanks to its programmable nature, Holistics can support this capability natively. This will enable popular use cases such as:
- **[Clients Dashboarding](#client-dashboarding)**: Build the same set of models/datasets/dashboards for clients but different data source for each client underneath.
- **[Dev/Prod environment](/docs/continuous-integration/dynamic-dev-prod-environment.md)**: Dynamically switch the underlying data sources (from dev → prod and vice versa) based on the environment that analysts are working on
- **[Dynamic data sources for embedded analytics](/embedded/dynamic-data-sources.md)**: Embedded analytics but different customers use different databases.
VIDEO
## Approach
In Holistics, you can **specify a function/expression in the data source definition** (of dataset or data model).
```tsx
// In a dataset
Dataset sales {
models: [ orders ]
// highlight-next-line
data_source_name: function_or_expression_here
}
// Or in a data model
Model orders {
// highlight-next-line
data_source_name: function_or_expression_here
dimension order_id { ... }
dimension user_id { ... }
...
}
```
The below example use an "if expression" to tell Holistics to use different database when doing development vs when being published.
```tsx
Dataset sales {
label: 'Dynamic Client Dataset'
models: [ ... ]
relationships: [ ... ]
// highlight-start
data_source_name:
if (H.git.is_production) {
'production_data_source'
} else if (H.git.current_branch == 'staging') {
'staging_data_source'
} else {
'development_data_source'
}
// highlight-end
}
```
### Some notes
- **Ensure data sources connected:** You need to make sure you have defined the 3 data sources named `production_data_source`, `staging_data_source`, and `development_data_source`.

- **Run-time evaluation:** The expression is evaluated at run-time (i.e when dashboard is viewed, or queries are generated to send to database.
- `data_source_name` is only available when defining data model or dataset, not dashboard. The dataset's data source will override whatever defined in data model.
- It is recommended to create a **separate file named `database.aml`** to store your dynamic data sources' logic, rather than defining it directly within your dataset or model file.
```typescript
// database.aml file
const dynamic_db = if (H.git.is_production) {
'production_data_source'
} else if (H.git.current_branch == 'staging') {
'staging_data_source'
} else {
'development_data_source'
}
```
```tsx
// your model file
Model users {
// highlight-next-line
data_source_name: dynamic_db
}
// your dataset file
Dataset ecommerce {
// highlight-next-line
data_source_name: dynamic_db
}
```
## Available Variables
Dynamic data source expressions can reference the current user and Git environment at runtime. For the full reference, see [AML user attributes and variables](/reference/aml/user-attributes-and-variables).
For example, this setup uses the current user's `data_source` attribute in production, but always uses a development database while modeling:
```aml
Dataset sales {
// highlight-start
data_source_name:
if (H.git.is_production) {
H.current_user.data_source
} else {
'development_data_source'
}
// highlight-end
}
```
## Example: Dynamic Data Source at User Level {#client-dashboarding}
Suppose you have many different customers who want the same set of reports. You maintain different data source for each customer. You want each customer when logging in to Holistics will be able to see the same reports but pointing to their respective database.
Here's how you can utilize user attributes in Holistics to achieve that.

### 1. Connect databases
First, **[connect](/docs/connect/)** to all of your customers' databases.
### 2. Define new user attribute & set values for each user
Go to Users (or Group Management) in Holistics, define a new attribute named `data_source` (or any name you prefer; the attribute name just needs to match what you reference in your AML code). You can do this either at the user level or user group level.

Once done, to go each user and set the corresponding `data_source` value for them. For more information, refer to [User Attributes](/docs/admin/user-attributes)
### 3. Write dynamic code to set data source
```tsx
Dataset dynamic_client_dataset {
label: 'Dynamic Client Dataset'
// The underlying data source will be dynamically switched based on who use it
// highlight-next-line
data_source_name: H.current_user.data_source
models: [ revenue ]
relationships: [ ]
}
```
### 4. Making sure that it works as expected
To make sure your setup work correctly, go to **"Organization Settings > View and Edit as"** under App Settings to test with each account.
For more details, please refer to [Log In As Another User](/docs/admin/impersonation)

By selecting a particular user or user group, the corresponding value of their user attribute (in this case, the data source name) will be applied to the `data_source_name` property of the dataset (and model). This allows the same dashboards to display different data for different users.
:::info Important note
Please be aware that testing this setup in the Development environment is not possible, particularly for users without access to the Modeling layer (such as Explorers or Viewers).
Therefore, you must first publish your changes to the Production environment before utilizing the **"View and Edit as"** option.
:::
---
## Dynamic Schemas
:::warning Model Persistence
Dynamic Schema does not support Query Model Persistence. Please check your data model persistence before using this feature.
:::
## Introduction
The Dynamic Schema feature also supports all the use cases mentioned in [Dynamic Data Sources](/docs/development/dynamic-data-source#use-case).

Unlike those who differentiate their data by data sources, some users prefer to organize data by `schema` (or in BigQuery, they call it `dataset`) within a single source. For these users, the Dynamic Schemas feature has got you covered.
## How to use
This feature lets you switch schemas easily using basic string interpolation in your model's table_name property.
Here’s a quick example to show how it works (in the use case where you separate your dev/prod by schema):
```typescript
// This is for dynamic schema use case by branch
const dynamic_schema =
if (H.git.is_production) { 'prod' }
else if (H.git.current_branch == 'staging') { 'stg' }
else { 'dev' }
// in Table Model
model users {
type: 'table'
data_source_name: 'your_db'
// dynamic schema in table name
//highlight-next-line
table_name: '${dynamic_schema}.users'
}
// in Query Model
model derived_users {
type: 'query'
data_source_name: 'your_db'
// dynamic schema in a query
//highlight-next-line
query: @sql select * from ${dynamic_schema}.users ;;
}
```
---
## The index.aml file
:::caution Disclaimer
This page is applicable to **AML 1.0** only. **AML 2.0** has deprecated the use of `index.aml` file.
:::
## What is index.aml file?
The `index.aml` is a special file that controls which **AML datasets** are available to be explored. Only the datasets specified in this file are displayed and can be used in **Reporting** and **Data Exploration**.
## Use index.aml to make datasets available in Production
For your datasets to be available and ready to be used in **Production**, it is important that you:
**Step 1:** If you do not have an `index.aml` file in your AML project, create one.
**Step 2:** Include your AML Dataset paths in the `index.aml` file.
**Step 3:** *Save* your changes.
**Step 4:** *Deploy to production* to make the changes live.
## Common issues when you forget to include datasets in index.aml
### Your Dataset is able to be explored in Preview mode, but it is not available in Production
If you can explore your AML Datasets using Preview mode, but are not able to see them in actual Data Exploration, **chances are that you have not included them in the `index.aml` file**.
To fix this issue, refer to this section [Use index.aml to make datasets available in Production](#use-indexaml-to-make-datasets-available-in-production).
---
## Local development with AI agents
## Introduction
Holistics supports using a coding agent (Claude Code, Cursor, GitHub Copilot, Codex) for BI development. You edit AML locally in your IDE, the [Holistics CLI](/docs/cli) keeps those files in continuous sync with a cloud dev branch, and the [MCP server](/docs/ai/mcp-server) gives the agent access to your workspace to run queries, get sample data, or inspect data warehouse schemas.
Three moving parts:
- **Holistics CLI**: bidirectional sync between your local files and a Holistics dev branch
- **MCP server**: live workspace access for the agent (explore models, run AQL, inspect schemas)
- **Holistics skills**: reusable AML/AQL workflows the agent can invoke — the `holistics-development` skill also automates CLI authentication, MCP connection, and sync setup
:::tip Prefer a plain IDE, no agent?
The CLI sync workflow stands on its own. Follow steps 1–2 below to set up your project, then see the [Holistics CLI](/docs/cli) guide to authenticate and run `holistics sync-code`.
:::
## Prerequisites
Before you begin, make sure you have:
- A Holistics project [connected to an external Git repository](/docs/git-version-control/external-git) (GitHub, GitLab, Bitbucket, etc.)
- A coding agent (Claude Code, Cursor, GitHub Copilot, Codex). Optional if you only want IDE + sync.
## How it fits together

## Setup
### 1. Enable Git Workflow and connect repository
In Holistics, enable **Git Workflow** for your project and connect it to an external Git repository (GitHub, GitLab, or Bitbucket).
This is a one-time setup per project.
See [Connect to an external Git repository](/docs/git-version-control/external-git) for the full guide.
### 2. Set up your local project
Clone the repository, create a branch, and open it in your IDE:
```bash
git clone git@github.com:your-org/your-holistics-repository.git
cd your-holistics-repository
git checkout -b feature/add-new-dashboard
```
A typical project structure looks like:
```
your-holistics-repository/
├── models/
│ ├── orders.model.aml
│ └── users.model.aml
├── datasets/
│ └── ecommerce.dataset.aml
└── dashboards/
└── sales_overview.page.aml
```
### 3. Install Holistics skills
Open the project in Claude Code. Run these two commands one at a time (Claude Code only accepts a single slash command per submission, so do not paste both at once).
First, add the [holistics/skills](https://github.com/holistics/skills) marketplace:
```text
/plugin marketplace add holistics/skills
```
Then install the `holistics-development` plugin:
```text
/plugin install holistics-development@holistics-skills
```
Prefer the interactive menu? Run `/plugin`, then browse to `holistics-skills` > `holistics-development`.
For other AI agents, import the skill files directly from the repository.
### 4. Run `/setup-amql-development`
Trigger the skill in Claude:
```text
/setup-amql-development
```
The skill walks you through and automatically handles:
- Installing and authenticating the [Holistics CLI](/docs/cli)
- Connecting the [MCP server](/docs/ai/mcp-server) to your agent
- Starting a continuous `holistics sync-code` session
Follow the prompts from the skill. When it completes, the skill connects your agent to your workspace and starts the sync.
## Daily workflow
With the pieces above wired up, the loop looks like:
```diagram
╭─────────────────╮ describe change ╭──────────────────╮
│ You │ ───────────────────────▶ │ Coding Agent │
╰────────▲────────╯ ╰─────────┬────────╯
│ │
preview │ │ explore + query
+ tweak │ │ (CLI + Skill)
│ ▼
╭────────┴────────╮ ╭──────────────────╮
│ Holistics UI │ │ Local AML files │
│ (dev branch) │ │ (in IDE) │
╰────────▲────────╯ ╰─────────┬────────╯
│ │
│ holistics sync-code │
╰─────────────────◀───────────────────────────╯
bidirectional sync
```
1. Describe the change you want to your agent: a new model, a new metric, a dashboard tweak.
2. The agent explores your workspace through MCP, edits AML files locally, and follows the patterns from the installed skills.
3. `sync-code` pushes each save to your Holistics dev branch within seconds.
4. Preview in the Holistics UI. Any tweaks you make there sync back to your local files.
5. Iterate with the agent until the change is ready.
## Ship to production
### Commit and push
When your change is ready for review, commit and push your branch:
```bash
git add .
git commit -m "Add new sales dashboard"
git push -u origin feature/add-new-dashboard
```
Then open a pull request through:
- Your Git tooling (`gh pr create`, GitLab CLI, etc.)
- Your Git host's web UI (GitHub, GitLab, etc.)
- The [Holistics web UI](/docs/continuous-integration/github-pr-workflow)
### Code review and merge
Have your team review the changes. Once approved, merge to your master branch.
### Publish
Publish either:
- Manually through the Holistics UI
- Set up a GitHub Action to auto-publish on merge using the [Publish API](/docs/continuous-integration/auto-publish)
- Call the Publish API locally
## Tooling
### Validation
You can validate your AML at any time, either locally with the CLI or in CI/CD with the Validation API:
- **[CLI validation](/docs/cli/aml#validate)** for local validation:
```bash
holistics aml validate
```
- **[Validation API](/docs/continuous-integration/validation-api)** for project-level validation in CI/CD pipelines:
```bash
curl -X POST https:///api/v2/aml_studio/projects/submit_validate \
-H "X-Holistics-Key: $HOLISTICS_API_KEY" \
-d '{
"branch": "feature/add-new-dashboard"
"commit-oid": "abc123def456",
}'
```
Both approaches integrate cleanly into CI/CD workflows. For example, you can set up a GitHub Action to run `holistics aml validate` on every push to ensure code quality before merging.
### Publish
Trigger a publish from a script or CI/CD using the [`submit_publish` endpoint](/api/v2/reference/aml-studio-projects-submit-deploy). The endpoint returns a job ID that you can poll via `/jobs//result` for completion.
```bash
curl -X POST \
-H "X-Holistics-Key: $HOLISTICS_API_KEY" \
-H "Content-Type: application/json" \
"https:///api/v2/aml_studio/projects/submit_publish"
```
Expected response:
```json
{
"job": {
"id": "abc123",
"status": "running"
}
}
```
Pair this with the [Validation API](/docs/continuous-integration/validation-api) on PRs and an auto-publish action on merge for end-to-end CI/CD. See [Auto-publish on merge](/docs/continuous-integration/auto-publish) for a full GitHub Action example.
### VS Code extension
The [Holistics VS Code extension](/docs/development/vscode-extension) adds IntelliSense, hover docs, go-to-definition, and real-time diagnostics for AML. Useful for human-authored edits, and agents running inside VS Code or Cursor pick up the same hover info and definitions as extra context.
### Lineage
:::tip Coming soon
`holistics aml lineage` will let you dump your project's lineage graph as JSON for refactor planning and integrations with external metadata tools. Stay tuned.
:::
## Troubleshooting
### Changes not appearing in Holistics
- Confirm `holistics sync-code .` is still running in your terminal (it stops on `Ctrl+C` or when the terminal closes)
- Make sure you're previewing the same branch the sync is configured for
- If `sync-code` is not running, you can fall back to `git commit && git push` and switch branches in the Holistics UI to preview
### Validation errors
- Run `holistics aml validate` locally before pushing
- Check the [AML reference](/as-code/aml) for syntax details
### Merge conflicts
- If both local and UI changes were made on the same branch, resolve conflicts locally with `git pull --rebase` before pushing
## What's next
- [Use AI agents with MCP Server](/docs/ai/mcp-server)
- [Connect to an external Git repository](/docs/git-version-control/external-git)
- [Set up PR workflows with GitHub](/docs/continuous-integration/github-pr-workflow)
- [Configure CI/CD with the Validation API](/docs/continuous-integration/validation-api)
- [Auto-publish on merge](/docs/continuous-integration/auto-publish)
---
## Project structure
## Introduction
In Holistics, a project is a collection of files that defines all the basic components of Holistics Modeling Layer (data models, datasets, relationships) as well as Holistics Reporting Layer (Dashboard, Visualization).
:::info Note
Each Holistics project has an associated Git repository. By default, Holistics manages your project's Git repository for you, but you can also manage it yourself with your own [External Git providers](/docs/git-version-control/external-git).
:::
## Project files
```
project-name (root)
|- models
| |- model-file-1.model.aml
| |- model-file-2.model.aml
| |- model-file-n.model.aml
|- datasets
| |- dataset-file-1.dataset.aml
| |- dataset-file-n.dataset.aml
|- dashboards
| |- dashboard-file-1.page.aml
|- README.md
```
Typically a project consists of these files:
- **Model** files (extension `.model.aml`) correlates with a native table in your database or a SQL query. Within a model file, Analysts will define all the dimensions, measures that will appear in the UI for users to explore and get insights.
- **Dataset** files (extension `.dataset.aml`) is the collection of related models and how they're linked with each other. Within a dataset file, analysts will decide which models should be included in the dataset for users to explore and build reports.
- **Dashboard** files (extension `.page.aml`) represent a [Canvas Dashboard](/docs/dashboards/).
## Guide to organize project files
An AML project is just a bunch of .aml files, so technically we can just order them in any ways we want. However, for consistency and ease of navigation, we can follow this structure:
```
.
|-- data_source
| |-- datasets
| | |-- name_by_team_or_usage
| |-- models
| | |-- name_by_source_applications
| | |-- name_by_usage
| | |-- ...
```
**Explanation:**
- `data_source`: Models and datasets from **different data sources** should be separated into different parent folders.
``- `models` folder contains `.model.aml` files. It is best to mirror the dbt project structure here.
---
## Reporting Validation
:::info
Reporting Validation only applies to [**Quick Dashboards**](/docs/dashboards/quick-dashboards). For [**Canvas Dashboards**](/docs/dashboards/), validation is [handled automatically](/release-notes/2025-08-field-ref-validation).
:::
## Introduction
In your data team’s workflow, changes to data models and datasets can disrupt active reports and dashboards, causing workflow interruptions and impacting decisions across the organization.
To address this challenge, we introduce **Reporting Validation**, a one-stop shop for managing issues with reporting items (reports, dashboards, filters, etc.) arising from changes in modeling assets (data models, datasets, fields, etc.). Analysts can:
- Proactively assess how upcoming changes affect active reports and dashboards before deployment.
- Pinpoint and fix previously broken reports and dashboards resulting from past changes.
## When to use Reporting Validation
- **Fix errors after deployment**: Identify and fix the broken reporting items caused by changes in the Modeling Layer.
- **Find and replace names of fields, models, or datasets**: If you want to change the naming convention of your fields, models, or datasets, Reporting Validation can help you locate all instances where they are referenced and replace them with the new names.
## How to use Reporting Validation
To ensure the integrity of your Reporting Layer and identify potential issues or compromises, follow these steps:
- **Step 1**: Navigate to the Reporting Validation tab, located in the bottom panel.
- **Step 2**: Click the `Validate Reporting Items` button. This action will generate a list that highlights Reports, Dashboard Filters, Data Alerts, and other components that may be affected by changes in the Modeling Layer.
When using Reporting Validation, you'll encounter three types of Validation Results:
- **Error**: This indicates that issues exist in the references, even before any changes are made within the Modeling Layer.
- **Warning**: A warning signifies that dependencies might break when modifications are made and deployed to the Reporting Layer. It serves as an early alert for possible issues.
- **Fixed**: Any prior errors made will be corrected when you deploy your changes to Production.
## Using Reporting Validation
To better illustrate this feature, here are some common use cases (but might not be all of them):
### To fix an already broken reporting item
If a reporting item is already broken on Production, you can use Reporting Validation to identify ongoing issues by **clicking on the underlying definition (model, field)** to automatically navigate to the location of the problem. From there, you can take the necessary actions to correct or fix the issue.
### To replace the names of fields and models
You can use Reporting Validation to search for and replace the names of fields and models across your project, making it easier to maintain consistency in your reporting.
### To fix issues related to modified datasets
On the other hand, if you make changes to your dataset, you will need to fix the error before proceeding with the deployment.
Changes that might cause this error:
- Altering the dataset name in the Development workspace
- Removing one of the existing datasets in Development workspace.
For example, if you change your dataset name from `ecommerce` to `ecommerce_1` in your dataset file.
After clicking `Publish`, an error will be raised because there are reports created from that dataset in Reporting.
You will need to either **fix the error** or **cancel the deployment** to continue.
By clicking on Update, you will be able to update your widgets and point them to another dataset in Development.
## FAQs
### Does Holistics provide error validation at field and model levels?
At the moment, we do not currently support this option yet.
---
## VS Code extension
The [Holistics AML extension](https://marketplace.visualstudio.com/items?itemName=holistics.holistics-aml-vscode-ext) is the official VS Code extension for editing AML locally. It gives you the IDE features you'd expect for a real programming language: autocomplete, errors as you type, hover documentation, and jump-to-definition across your project.
Works equally well in VS Code, Cursor, Windsurf, and any other VS Code-compatible editor.

## Features
### Syntax highlighting
Rich highlighting for AML, with embedded SQL inside `query { ... }` blocks highlighted as actual SQL rather than as a string.
{/* TODO: screenshot of an AML model with embedded SQL block, both languages colorized */}
### IntelliSense (autocomplete)
Completions for AML keywords, model fields, dimensions, and measures. As you type a reference like `orders.`, the extension suggests the available fields and metrics on the `orders` model.
{/* TODO: screenshot of `orders.` triggering a completion popup */}
### Hover information
Hover any symbol to see its type and any documentation attached to it: model descriptions, dimension types, relationship details.
{/* TODO: screenshot of hovering a dimension showing its type and description */}
### Go to definition
`Ctrl+Click` (or `F12`) on a model, dimension, or metric to jump to where it's defined. Works across files in your project.
{/* TODO: screencast of clicking a `ref` and jumping to the source file */}
### Real-time diagnostics
Errors appear in the editor as you type: invalid references, type mismatches, syntax errors. No need to switch to the Holistics UI to find out something is broken.
{/* TODO: screenshot of a squiggly underline on a broken `ref` with the error tooltip */}
### Code folding
Collapse models, datasets, dashboards, or any block to keep large files navigable.
## Install
From the VS Code marketplace, search for "Holistics" and install the official extension by the `holistics` publisher.
Or install from the command line:
```bash
code --install-extension holistics.holistics-aml-vscode-ext
```
For Cursor, Windsurf, or other VS Code forks, install the same extension from your editor's marketplace UI.
The extension activates automatically when you open a workspace containing any `.aml` file.
## Configuration
| Setting | Description | Default |
|---------|-------------|---------|
| `aml.enableHolisticsObjects` | Enable Holistics objects (model, dataset, dashboard, etc.) in your local workspace | `true` |
Set this to `false` if you're working with pure AML outside a Holistics project context.
## Pairs well with
The extension is most useful as part of a full local development setup:
- [Holistics CLI](/docs/cli) and [`sync-code`](/docs/cli/sync-code) — keep your local files in continuous sync with a Holistics dev branch so changes preview live
- [MCP server](/docs/ai/mcp-server) — give coding agents (Claude Code, Cursor, Copilot) live access to your workspace
- [Local agentic development](/docs/development/local-agentic-development) — the end-to-end workflow with agents, MCP, and sync
## Troubleshooting
### Extension not activating
The extension only activates when your workspace contains at least one `.aml` file. Open a folder containing AML, not a single `.aml` file directly.
### Autocomplete missing fields
Confirm the model file is in the same workspace folder VS Code has open. Cross-project references won't resolve.
### Stale diagnostics after editing
Use the **Developer: Reload Window** command (`Cmd+Shift+P` / `Ctrl+Shift+P`) to refresh the language server.
## What's next
- [Local agentic development](/docs/development/local-agentic-development)
- [AML reference](/as-code/aml)
- [Holistics CLI](/docs/cli)
---
## Why Holistics
What problem Holistics solves, why other BI tools struggle with the same problem, and what we do differently.
## The promise that keeps breaking
Every BI tool now ships an AI assistant. Some translate questions directly to SQL against the raw warehouse and guess at table joins, date columns, and what business metrics actually mean. The more sophisticated ones ground their AI in a semantic layer. Both run into the same wall on real analytical questions like period comparisons, cohort retention, and ratios across grains: either there's no semantic layer at all, or the semantic layer underneath can only carry first-order queries. AI inherits that limit. Analysts end up verifying every output. The queue doesn't shrink. It changes shape.
Self-service analytics has the same shape of failure. The first question works ("revenue by region last quarter"). The second one breaks ("revenue by region last quarter, compared to the same period last year, for customers active in both periods"). The user falls back to filing a ticket, or pulling the data into a spreadsheet, or, worst case, making a decision on a number that's subtly wrong.
Both failures share one root cause.
## The semantic ceiling
Most BI tools have a **semantic layer**: the place where metrics, dimensions, and relationships get defined so that everyone queries the same logic. The problem is that most semantic layers can only express **first-order queries**: pick a metric, slice it by a dimension, filter it, group it.
The moment a real analytical question shows up, the semantic layer can't carry it. Period-over-period comparisons, cohort retention, ratios across grains, nested aggregations: the logic leaks out into derived tables, spreadsheets, dashboard formulas, one-off SQL. We call this the **semantic ceiling**, and the leakage is **semantic leakage**.
Here's a concrete shape. Ask a typical BI tool: *"for each country, give me median revenue per buyer."* That's a sub-aggregation: sum revenue per buyer first, then take the median per country. SQL handles it with one CTE. Most semantic layers can't, because measures can't reference other measures. The standard workaround is a pre-built derived table that locks the inner dimension, so asking the same metric by *marketing source* requires another derived table. Multiply by every dimension and pattern, and the workaround grows linearly with question variety. See [Nested aggregation: Looker vs Holistics](/docs/from-others/looker/nested-aggregation) for the full walkthrough with code, diagrams, and a video.
This is why AI assistants on top of those tools fail in the same way. AI inherits the limits of the semantic layer it reasons from. If the semantic layer can't express "revenue per active customer in the comparable period last year," neither can the AI. It will either refuse, or guess, or quietly produce a confident-looking number that's wrong.
## What most semantic layers can't do
The semantic ceiling shows up in three structural gaps that most BI tools share:
1. **Not programmable.** Most semantic layers are defined in YAML configs. YAML is schemaless (no type checking until runtime), ambiguous (different parsers handle values differently), and offers no abstractions. Reuse requires Jinja templating, which breaks at runtime and is impossible to debug. You can't build modules, extend definitions, or use conditionals the way a real programming language allows.
2. **Not composable.** Most semantic layers treat metrics as SQL strings tagged with metadata. A metric can't reference another metric. Period-over-period comparisons, cohort retention, and ratios across grains require pre-built derived tables (which lock dimensions) or leak into dashboard formulas and spreadsheets. The moment a user asks a variation, the semantic layer can't carry it.
3. **Not truly as-code.** Most BI tools are UI-first with Git export as an afterthought. Changes happen in the UI and get synced to Git after the fact. Pull requests review the artifact, not the intent. Environments (dev/staging/prod) either don't exist or require manual copying. The "as-code" label gets applied without the durability or governance that engineering teams expect from version control.
These gaps compound: without programmability, you can't define reusable abstractions. Without composability, you can't express variations. Without true as-code, you can't govern what escapes. The semantic layer becomes a table of contents instead of a system of record.
## What Holistics does differently
Holistics raises the semantic ceiling with a uniquely expressive semantic layer, and keeps what's underneath governed with analytics-as-code infrastructure. The semantic layer is the differentiator; analytics-as-code is what keeps it durable.
### The differentiator: an expressive semantic layer
The semantic layer itself is written in **AML**, a typed modeling language with first-class abstractions, not YAML configs. Layered on top, **AQL** is a composable query language for metrics. The two operate at different levels of the stack: AML makes the semantic layer programmable; AQL makes the query/metric layer composable. Both are typed and IDE-supported, both designed because YAML configs and SQL strings can't carry real analytics logic.
**AML, typed modeling language.** Most BI tools that call themselves "analytics-as-code" use YAML, but YAML is not a programming language. It's schemaless (no type checking until runtime), ambiguous (`enabled: yes` parses as a boolean in Python and a string in Node), and offers no abstractions, so reuse degrades into Jinja templating that breaks at runtime. [AML (Analytics Modeling Language)](/reference/aml/) is a typed language purpose-built for analytics. Models, dimensions, measures, datasets, and relationships are first-class language constructs, not generic key-value structures. Modules, extends, partials, constants, and functions keep analytics code DRY at scale. The full IDE experience (autocomplete, inline docs, go-to-definition, instant compile-time errors) comes with the language. See [AML vs YAML](/as-code/amql/aml-vs-yaml) for the structural argument.
**AQL, composable metric language.** In most BI tools, a metric is a SQL string tagged with metadata. [AQL (Analytics Query Language)](/as-code/aql/) treats metrics as **first-class composable objects** instead. A metric like `revenue` isn't just a `SUM(...)` expression; it's an object that can be combined with time logic, level-of-detail modifiers, period comparisons, and other metrics, all without falling back to SQL. Cohort retention, period-over-period comparison, percent-of-total, running totals, nested aggregations: these stay inside the semantic layer instead of leaking out. AQL compiles deterministically to SQL, and the compiled output is inspectable, so engineers can verify exactly what runs against the warehouse. See [AQL vs SQL](/as-code/aql/aql-vs-sql) for the structural argument.
The consequence for AI: when Holistics AI receives a natural-language question, it generates AQL against an AML-defined semantic layer. AQL is database-agnostic, composable, and aware of your existing metric definitions; AML is the typed, governed substrate those definitions live in. The AI doesn't reinvent revenue from raw tables. It reuses the definitions you already wrote, in languages built to be reused. See [Why Holistics AI is reliable](/docs/ai/architecture) for the full mechanism.
### The durability backbone: analytics-as-code
Every definition in Holistics is **code, in a Git repository**. Models, metrics, datasets, dashboards, relationships, permissions: all of it.
That means business logic gets:
- **History.** Every change has an author, a timestamp, and a diff.
- **Review.** Changes go through pull requests. Wrong answers don't get merged silently.
- **Branches.** Experiment in isolation; promote when ready.
- **Environments.** Develop in dev, test in staging, ship to prod through a real promotion workflow.
This is what makes the foundation **durable** rather than mutable. The semantic layer doesn't drift. Stakes go up, and the substrate AI reasons from gets stronger over time, not weaker.
See the [Analytics-as-Code](/docs/analytics-as-code/) overview for how this plays out in practice.
## What these foundations enable
Every BI tool advertises these outcomes. The foundations above are why ours hold up under real questions.
| Outcome | Why it actually works in Holistics |
|---|---|
| **Trusted AI analytics** | AI reasons from the same composable, governed definitions humans use, not raw schema |
| **Governed self-service** | The semantic layer carries cohort, period, and ratio questions natively. Variations stay inside the layer instead of leaking into spreadsheets |
| **Embedded analytics** | The same governed layer powers customer-facing AI and dashboards. One definition, many surfaces |
| **Developer-friendly BI** | Inspectable compiled SQL, IDE tooling, type checking, CI/CD: engineering practices applied to BI |
## How this compares
| | Typical BI tool | Holistics |
|---|---|---|
| **Semantic layer definition** | YAML configs (schemaless, Jinja workarounds) | AML (typed language with first-class abstractions) |
| **Metric definition** | SQL strings that can't combine | AQL (composable objects that can reference each other) |
| **Programmability** | Limited to YAML + Jinja templates | Full language: modules, extends, conditionals, IDE support |
| **Composability** | Metrics can't reference metrics | Metrics compose: period comparisons, level-of-detail, ratios stay inside |
| **Follow-up questions** | Leak into derived tables, spreadsheets | Stay inside the semantic layer |
| **AI mechanism** | Natural language → SQL against raw schema | Natural language → AQL against semantic layer → SQL |
| **Governance model** | UI-first; Git export as afterthought | Code-first; Git-native; PRs, branches, environments |
| **Compiled SQL** | Often opaque | Always inspectable |
| **Embedding** | Separate product surface or limited | Same governed layer powers internal + customer-facing |
## Where to go from here
- **[How Holistics works](/docs/how-holistics-works)**: the end-to-end architecture and workflow
- **[Holistics AI](/docs/ai)**: how the AI is structurally different, and what it's good at
- **[AQL Overview](/as-code/aql/)**: the language behind the expressive semantic layer
- **[Analytics-as-Code](/docs/analytics-as-code/)**: how the foundation stays durable
- **[AML vs YAML](/as-code/amql/aml-vs-yaml)** and **[AQL vs SQL](/as-code/aql/aql-vs-sql)**: why we built our own languages
- **[Coming from Looker?](/docs/from-others/looker)**: the structural comparison and migration guide
---
## Aggregator Functions
:::danger Legacy Feature
Starting from **April 19, 2024**, Business Calculation and Holistics Expression are no longer supported for new user signups, and are only be available for legacy use cases.
Holistics Expression is now replaced by [AQL Expression](/as-code/aql/) with enhanced functionality.
:::
Aggregators are functions that group values of multiple rows into a single summary value. They are equivalent to aggregation functions that SQL supports (SUM, COUNT, AVG, MAX, MIN,...).
---
## count
count(field)
**Description**
Counts the total number of items in a group, not including NULL values.
**Return type**
Whole Number
**Example**
count(orders.id)
---
## count_distinct
count_distinct(field)
**Description**
Counts the total number of distinct items in a group, not including NULL values.
**Return type**
Whole Number
**Example**
count_distinct(orders.id)
---
## average
average(field)
**Description**
Averages the values of items in a group, not including NULL values.
**Return type**
Vary
**Example**
average(orders.id)
---
## min
min(field)
**Description**
Computes the item in the group with the smallest numeric value.
**Return type**
Vary
**Example**
min(order_item.quantity)
---
## max
max(field)
**Description**
Computes the item in the group with the largest numeric value.
**Return type**
Vary
**Example**
max(order_item.quantity)
---
## sum
sum(field)
**Description**
Sums the total number of items in a group, not including NULL values.
**Return type**
Number
**Example**
sum(order_item.quantity)
---
## median
median(field)
**Description**
Computes the median of an expression, which is the value that the values in the expression are below 50% of the time.
**Return type**
Number
**Example**
median(order_item.quantity)
---
## stdev
stdev(field)
**Description**
Returns the standard deviation (sample) of the column created by expression unless expression defines a column of lists, in which case returns the standard deviation (sample) of each list. .
**Return type**
Number
---
## stdevp
stdevp(field)
**Description**
Returns the standard deviation (population) of the column created by expression unless expression defines a column of lists, in which case returns the standard deviation (population) of each list.
**Return type**
Number
---
## var
var(field)
**Description**
Returns the variance (sample) of the column created by expression unless expression defines a column of lists, in which case returns the variance (sample) of each list.
**Return type**
Number
---
## varp
varp(field)
**Description**
Returns the variance (population) of the column created by expression unless expression defines a column of lists, in which case returns the variance (population) of each list.
**Return type**
Number
---
## Dealing with Nulls and Zeros
:::danger Legacy Feature
Starting from **April 19, 2024**, Business Calculation and Holistics Expression are no longer supported for new user signups, and are only be available for legacy use cases.
Holistics Expression is now **replaced by [AQL Expression](/reference/aql/null-and-zero-functions#coalesce)** with enhanced functionality.
:::
## coalesce
coalesce(val1, val2, ...., val_n)
**Description**
This function returns the first non-null value in a list
**Return type**
Vary
**Example**
Given a Holistics expression as below:
```
coalesce(yearly_payment, quarterly_payment, monthly_payment)
```
The result would be:
| Name | Yearly Payment | Quarterly Payment | Monthly Payment | Payment (coalesce) |
| ----- | -------------- | ----------------- | --------------- | ------------------ |
| Alice | 70.00 | NULL | NULL | 70.00 |
| Billy | NULL | 35.00 | NULL | 35.00 |
| Conte | NULL | NULL | 6.00 | 6.00 |
---
## nullif()
nullif(expr1, expr2)
**Description**
This function returns NULL if two expressions are equal, otherwise it returns the first expression.
**Return type**
Vary
**Example**
Given a Holistics expression as below:
```
nullif(sales_target, sales_current)
```
The result would be:
| Sales Person | Sales Target | Sales Current | Target to be achieved (nullif) |
| ------------ | ------------ | ------------- | ------------------------------ |
| Andy | 10,000 | 10,000 | null |
| Billy | 23,000 | 18,000 | 23,000 |
| Cindy | 21,000 | 21,000 | null |
| Danny | 0 | 10,000 | 0 |
---
## safe_divide
safe_divide(val1, val2)
**Description**
Equivalent to the division operator (X / Y), but returns NULL if an error occurs, such as a division by zero error.
**Return type**
Vary
**Example**
Given a Holistics expression as below:
```
safe_divide(X, Y)
```
The result would be:
| X | Y | safe_divide | X/Y |
| --- | --- | ----------- | ----- |
| 10 | 5 | 2 | 2 |
| 5 | 0 | null | ERROR |
| 11 | 2 | 5.5 | 5.5 |
---
## Holistics Expression
:::danger Legacy Feature
Starting from **April 19, 2024**, Business Calculation and Holistics Expression are no longer supported for new user signups, and are only be available for legacy use cases.
Holistics Expression is now replaced by [AQL Expression](/as-code/aql/) with enhanced functionality.
:::
## Definition
Holistics Expression is our propriety language that allows you to define data, perform transformations, and make calculations, etc. in a database-agnostic way. Essentially, it eliminates the nuances between different SQL dialects, allowing you to concentrate on your data logic.
## Why Holistics Expression is necessary
Firstly, in comparison to SQL, Holistics Expression serves more complex business use cases. For example, you can:
- Create a period-over-period growth percentage
- Analyze cumulative sum from the selected range
- Create custom fields combining data from multiple models instead of just the current model
- Apply Column Level Permission easily
- And so much more
Next, if you are working with multiple databases with different SQL dialects, Holistics Expression will help you to create dimension/measure flexibly with unified syntax that functions properly with all of your databases (and of course, with our system as well).
## Where to use Holistics Expression
Holistics Expression aims to support both Data Analysts and Explorers to efficiently create calculations thus fulfilling their analytics needs. However, while Analysts work primarily in the modeling layer and prepare reusable measures/calculations, Explorers, on the other hand, view prepared reports and only explore or create calculations on-demand, so that Holistics has introduced two places where Holistics Expression can be created and used.
### Business Calculation
[Business Calculations](/docs/business-calculation.md) can be created when exploring Dataset or Reports and Explorers are its target users. You can imagine it's like calculating 2 or multiple fields in an Excel Sheet. Its formula can be adjusted easily right in Dataset Exploration View.
### Model Field Expression
In contrast, **Model Field Expression** can only be created in a specific model and used when exploring the related model in Dataset. The formula of Model Field Expression cannot be modified in Dataset Exploration View.
## How Holistics Expression works
Holistics Expression provides a unified syntax against different SQL dialects for defining any measure (metric) on top of a set of models inside a dataset. For a simple measure which only involves a single model, a measure expression work just like an aggregate function in SQL. Behind the scene, when you use a measure in a dataset, it's translated to a simple SQL expression and put into the final query.
With more complicated measures that involve more models, since we know what you are computing, which models and relationships are involved, we can dynamically prepare these dependencies in different parts of the final query with respect to correctness and performance.
## Supported Functions
The functions can be divided into a few basic categories:
- **Aggregator Functions:** take values from multiple rows to perform a calculation
- **Logical Functions:** return value based on some logical conditions
- **Dealing with Nulls and Zeros functions**
- **Time Intelligence Functions:** Date- and time-related functions
- **(Not Available) Filter Functions:** filter expressions that are applied to a measure calculation
### [Aggregator Functions](docs/expression/aggregator-functions.md)
| Functions | Syntax | Purpose |
| --- | --- | --- |
| [count](aggregator-functions.md#count) | count(field) | Counts the total number of items in a group, not including NULL values |
| [count_distinct](aggregator-functions.md#count_distinct) | count_distinct(field) | Counts the total number of distinct items in a group, not including NULL values. |
| [average](aggregator-functions.md#average) | average(field) | Averages the values of items in a group, not including NULL values. |
| [min](aggregator-functions.md#min) | min(field) | Computes the item in the group with the smallest numeric value. |
| [max](/docs/expression/aggregator-functions.md#max) | max(field) | Computes the item in the group with the largest numeric value. |
| [sum](/docs/expression/aggregator-functions.md#sum) | sum(field) | Sums the total number of items in a group, not including NULL values. |
| [median](/docs/expression/aggregator-functions.md#median) | median(field) | Computes the median of an expression, which is the value that the values in the expression are below 50% of the time. |
| [stdev](/docs/expression/aggregator-functions.md#stdev) | stdev(field) | Returns the standard deviation (sample) of the column created by expression unless expression defines a column of lists, in which case returns the standard deviation (sample) of each list. |
| [stdevp](/docs/expression/aggregator-functions.md#stdevp) | stdevp(field) | Returns the standard deviation (population) of the column created by expression unless expression defines a column of lists, in which case returns the standard deviation (population) of each list. |
| [var](/docs/expression/aggregator-functions.md#var) | var(field) | Returns the variance (sample) of the column created by expression unless expression defines a column of lists, in which case returns the variance (sample) of each list. |
| [varp](/docs/expression/aggregator-functions.md#varp) | varp(field) | Returns the variance (population) of the column created by expression unless expression defines a column of lists, in which case returns the variance (population) of each list. |
| [running total](/docs/running-total) | | Shows how a metric has changed over time. This function can only be used in Dataset Exploration UI. |
### [Logical Functions](/docs/expression/logical-functions.md)
| Functions | Syntax | Purpose |
| --- | --- | --- |
| [case when](/docs/expression/logical-functions#case-when) | case(when: condition_expression, then: value_expression, else: value_expression) | goes through conditions and returns a value when the first condition is met (like an IF-THEN-ELSE statement). |
| [and](/docs/expression/logical-functions#and) | and(condition_expression, ...) | compares between two Booleans as expression and returns true when both expressions are true. |
| [or](/docs/expression/logical-functions#or) | or(condition_expression, ...) | compares two Booleans as expression and returns true when one of the expressions is true. |
| [not](/docs/expression/logical-functions#not) | not(field_expression) | takes a single Boolean as an argument and invert it. |
| [is](/docs/expression/logical-functions#is) | is(field_expression) | evaluates the given statement and return either True or False. |
| [in](/docs/expression/logical-functions#in) | in(field_expression, value_expression, value...) | takes a field expression and a list of values. Return true if that list of values contains the value of that field expression. |
### [Dealing with Nulls and Zeros functions](/docs/expression/functions-that-handle-null-or-zero-value.md)
| Functions | Syntax | Purpose |
| --- | --- | --- |
| [coalesce](/docs/expression/functions-that-handle-null-or-zero-value#coalesce) | coalesce(val1, val2, ...., val_n) | returns the first non-null value in a list |
| [nullif](/docs/expression/functions-that-handle-null-or-zero-value#nullif) | nullif(expr1, expr2) | returns NULL if two expressions are equal, otherwise it returns the first expression. |
| [safe_divide](/docs/expression/functions-that-handle-null-or-zero-value#safe_divide) | safe_divide(val1, val2) | Equivalent to the division operator (X / Y), but returns NULL if an error occurs, such as a division by zero error. |
### [Time Intelligence Functions](/docs/expression/time-intelligence-functions.md)
:::warning
Currently, Holistics only supports `epoch` function. Other functions are not available.
:::
| Functions | Syntax | Purpose |
| --- | --- | --- |
| [epoch](/docs/expression/time-intelligence-functions#epoch) | epoch(date); epoch(datetime) | Returns a Unix timestamp which is the number of seconds that have elapsed since ‘1970-01-01 00:00:00’ UTC |
| [date_trunc](/reference/aql/time-intelligence-functions#date_trunc) | date_trunc(datetime, time_col: 'year', 'quarter', 'month', 'week', 'day', 'hour', 'minute') | Truncate a TIMESTAMP on specific date part |
| now | now() | Returns current timestamp |
| day | day() | Extract the day from a given timestamp |
| month | month() | Extract the month from a given timestamp |
| year | year() | Extract the year from a given timestamp |
| quarter | quarter() | Extract the quarter from a given timestamp |
| week | week() | Extract the week from a given timestamp |
| hour | hour() | Extract the hour from a given timestamp |
| minute | minute() | Extract the minute from a given timestamp |
| week_day | week_day() | Return the day number of a specific date (within a week) |
| week_num | week_num() | Return the week number of a specific date (within a year) |
Please refer to **Reference** section on the left for more information.
## FAQs
### Holistics Expression is case sensitive
**Question**: Can I use `CASE(WHEN:...,THEN:...,ELSE:...)`, `AND()`, `OR()`,...
**Answer**: No, Since Holistics Expression is **case sensitive** and we don't support capitalized letters in our Expression so the exact needs to be followed
* `case(when:...,then:...,else:...)`
* `and()`
* `or()`
* ...
### How to create calculation with only a subset of my current data
**Question**: How can I create a calculation with only a subset of my current data (using condition inside an aggregate function).
For example, from the eCommerce dataset, what if I want to calculate the total value from the **delivered orders** only (exclude all other cancelled and refunded orders)
**Answer**: Since `Filter function` inside an `Aggregate function` is currently not supported, we recommend that at this moment, you can combine `measure` function with `case when` inside to calculate the data with any specific condition being applied.
```sql
sum(
case(
when: order_derived.order_status == 'delivered'
, then: order_derived.item_value
, else: null
)
)
```
### How to handle error Divide by 0
**Question**: When doing division in Business Calculation (field_a/field_b), sometimes I encounter `division by zero` error which is obviously because my Divisor = 0. How should I handle this case?
**Answer**: There are 2 ways to handle this case:
Option 1: Use `safe_divide` syntax.
Option 2: You can add conditional expression in your divisor to return NULL whenever it has the value of 0
```sql
sum(model.field_a)
/
case(
when: sum(model.field_b) == 0
, then: null
, else: sum(model.field_b)
)
```
### Can I add comments?
You can add comments using `//` syntax:
```sql
sum(
// only take delivered orders
case(
when: order_derived.order_status == 'delivered'
, then: order_derived.item_value
, else: null
)
)
```
---
## Logical Functions
:::danger Legacy Feature
Starting from **April 19, 2024**, Business Calculation and Holistics Expression are no longer supported for new user signups, and are only be available for legacy use cases.
Holistics Expression is now replaced by [AQL Expression](/as-code/aql/) with enhanced functionality.
:::
Logical functions return value based on some logical conditions.
## case when
case(when: condition_expression, then: value_expression, else: value_expression)
**Description**
The CASE statement goes through conditions and returns a value when the first condition is met (like an IF-THEN-ELSE statement).
**Return type**
Vary
**Example**
Given a Holistics expression as below:
```
case(
when: users.gender == 'm', then: 'male',
when: users.gender == 'f', then: 'female',
else: 'others'
)
```
The SQL output would be:
```
Case
When users.gender = 'm' then 'male'
When users.gender = 'f' then 'female'
Else 'others'
End
```
And the result would be:
| gender | case |
| ------ | ------ |
| m | male |
| f | female |
| m | male |
---
## and()
and(condition_expression, ...)
**Description**
Logical AND compares between two Booleans as expression and returns true when both expressions are true.
**Return type**
Boolean
**Example**
Given a Holistics expression as below:
```
and(
products.id >= 2,
products.id <= 8
)
```
The SQL output would be:
```
(
(products.id >= 2.0) AND (products.id <= 8.0)
)
```
And the result would be:
| id | and |
| --- | ----- |
| 1 | false |
| 2 | true |
| 8 | true |
| 9 | false |
---
## or()
or(condition_expression, ...)
**Description**
Logical OR compares two Booleans as expression and returns true when one of the expressions is true.
**Return type**
Boolean
**Example**
Given a Holistics expression as below:
```
or(condition_expression, ...)
```
The SQL output would be:
```
or(
products.id <= 2,
products.id >= 8
)
```
And the result would be:
| id | or |
| --- | ----- |
| 1 | true |
| 4 | false |
| 7 | false |
| 9 | true |
---
## not()
not(field_expression)
**Description**
Logical NOT takes a single Boolean as an argument and invert it.
**Return type**
Boolean
**Example**
Given a Holistics expression as below:
```
not(is(products.id, null))
```
The SQL output would be:
```
NOT (products.id IS NULL)
```
And the result would be:
| id | not |
| --- | ----- |
| 1 | true |
| | false |
| 3 | true |
| 4 | true |
---
## is()
is(field_expression)
**Description**
Logical IS evaluates the given statement and return either `true` or `false`.
**Return type**
Boolean
**Example**
Given a Holistics expression as below:
```
is(products.id, null))
```
The SQL output would be:
```
(products.id IS NULL)
```
And the result would be:
| id | not |
| --- | ----- |
| 1 | false |
| | true |
| 3 | false |
| 4 | false |
---
## in()
in(field_expression, value_expression, value...)
**Description**
`in` operator takes a field expression and a list of values. Return true if that list of values contains the value of that field expression.
**Return type**
Boolean
**Example**
Given a Holistics expression as below:
```
in(users.name, 'bob', 'alice', 'jack')
```
The SQL output would be:
```
user.name in ('bob', 'alice', 'jack')
```
And the result would be:
| name | in |
| ----- | ----- |
| bob | true |
| alice | true |
| peter | false |
---
## Time Intelligence Functions
:::danger Legacy Feature
Starting from **April 19, 2024**, Business Calculation and Holistics Expression are no longer supported for new user signups, and are only be available for legacy use cases.
Holistics Expression is now replaced by [AQL Expression](/as-code/aql/) with enhanced functionality.
:::
---
## epoch()
epoch(date)
epoch(datetime)
**Description**
`epoch` returns a Unix timestamp which is the number of seconds that have elapsed since ‘1970-01-01 00:00:00’ UTC.
You can use this function to return a Unix timestamp based on the current date/time or another specified date/time.
**Return type**
Vary
**Example**
Given a Holistics expression as below:
```
epoch(created_at)
```
The result would be:
| created_at | epoch(created_at) |
| ------------------- | ----------------- |
| 2018-06-12 09:26:49 | 1528795609 |
| 2018-06-12 | 1528761600 |
---
## Facebook Ads Setup
:::warning Deprecation
Since December 2021, we are no longer supporting the new version of importing data from Facebook Ads. Please check out the announcement and suggested solution [here](https://docs.holistics.io/faqs/el-deprecation).
:::
The **Facebook Ads Data Model** lets you load data from your Facebook Ads to your relational databases.
# Model your Facebook Ads data
:::warning Requirement
Please note that you need a SQL database to load your Facebook Ads data into. If you don't have any, please check out our guide [here](/docs/connect/dont-have-sql-database).
:::
## 1. Go to Data Modeling page
At this step, you must already have a modeling-support source. Select the source that you want to load your Facebook Ads into.
##2. Create Data Model from Data Import
Click on Create, choose Data Import and select **Facebook Ads**.
##3. Connect to Facebook Ads
By default, your first Facebook Ads connection in the list will be selected. If you want to choose another Facebook Ads Connection, simply click on the Dropdown to **select another one** or **connect to new data source**
### 3.1. Connect your facebook ads database
So what you need for the connection are **Access Token** and **Ad Account ID**.
### 3.2. Get your Ad Account ID
Please login to the facebook account that manages the data you want to explore.
Then go to your Ad Account Setup
You will find your **Ad Account ID** as in the image:
### 3.3. Setup your facebook app
Create a new facebook app if you haven't got one: please refer this guide on how to do it.
Holistics is using API v5.0. Please set permitted API version to v5.0 in **Settings** -> **Advanced**:
### 3.4. Add Marketing API to your app
In your app dashboard, add a new product called **Marketing API**:
Now go to tab **Tools** under **Marketing API**. In section **Get Access Token**, check **ads_read**, then click **Get Token**
You will see your generated token. Copy and paste it to field **Access Token** in your new data source form.
Now that you can click **Test Connection** and connect to your facebook ads data!
##4. Advanced Settings
From [Advanced Settings](import-models#advanced-settings) you can modify the destination table from Destination Settings, and control how column types will be cast from Sync Configuration. Please visit the dedicated page for more details.
##5. Additional data
Here are details of our supported tables.
### Campaign
| COLUMN | TYPE |
|-------------|----------------|
| id | Numeric string |
| name | varchar |
| objective | varchar |
| account_id | Numeric string |
| buying_type | varchar |
| spend_cap | int |
| status | varchar |
| start_time | date |
| stop_time | date |
| update_time | date |
### Adset
| COLUMN | TYPE |
|-------------------|----------------|
| id | Numeric string |
| name | varchar |
| adlabels | varchar |
| account_id | Numeric string |
| billing_event | varchar |
| daily_budget | int |
| budget_remaining | int |
| optimization_goal | varchar |
| campaign_id | numeric string |
| created_time | date |
| start_time | date |
| end_time | date |
### Ad
| COLUMN | TYPE |
|------------------|----------------|
| id | numeric string |
| name | varchar |
| adset_id | numeric string |
| account_id | numeric string |
| campaign_id | numeric string |
| adlabels | varchar |
| bid_type | varchar |
| bid_info | varchar |
| created_time | date |
| effective_status | varchar |
---
## Field Types: Dimensions, Measures
The basic structure for every report consists of dimension and measure fields. In Holistics, you will interact with two types of fields to explore and create charts:
- **Dimensions** are non-aggregated data fields displayed in black. Dimensions can be of any data type.
- **Measures** are aggregated data fields (counting, summing, averaging, etc.). Measures always return a numerical result and are displayed in blue along with a sigma icon.
Additionally, you can also **Add Business Calculation** to create custom fields from the available dimensions and measures.
To learn more about this Business Calculation, please refer to this [documentation page](/docs/business-calculation).
---
## Filter with Condition Group
Holistics allows you to create complex conditions (nested and/or expressions) using Condition Group. Create OR conditions between different fields, nest multiple AND/OR conditions together into groups.
To use, simply go to a **Report** exploration or editing mode, then go to **Conditions** section under the Visualization Settings. Click **Add Condition Group** and start exploring!
**Is Condition Group also available for Dashboard, and Data Alert?**
At the moment, Condition Group is only available at Report level. We are analyzing the use cases and an optimal solution for this kind of filtering on Dashboard and Data Alert. You can keep track of our progress on our [Public Product Roadmap](https://www.notion.so/8cdb082325c64dc9bcfc4c7701f9d9bc?pvs=21).
---
## Date Filter
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Natural Time Expression](/docs/datetimes/relative-dates)
:::
Holistics provides several date filtering operators that can be used with [Dashboard Filter](/docs/filters), Report Conditions, and Email Schedule Filters.
:::info Note
Date filters will recognize days in relation to the actively applied timezone. This may affect how reports will be seen by different viewers. Head over to [Timezone Settings](/docs/datetimes/timezones) to learn more about the timezone settings available to your organization.
:::
## Introduction
Date Filter can be applied to fields of Date and Datetime [data types](/docs/data-types). Currently we support the following date comparison operators:
Operator
Description
Example
Dates selected
is on
Select the exact date
is on 2024-01-01
2024-01-01
between
Select a date range between two dates, including the boundaries
between 2024-01-01 and 2024-01-03
2024-01-01, 2024-01-02, 2024-01-03
before
Select all dates before (not including) a particular date
before 2024-01-31
all dates up to 2024-01-30
after
Select all dates after (not including) a particular date
after 2024-01-01
all dates from 2024-01-02 onward
last
Select the previous N complete time periods. By default, the current incomplete time period is excluded, but can also be included.
Today is 2024-01-15, and we select last 3 months
All dates in the months: Oct 2023, Nov 2023, Dec 2023. Jan 2024 is excluded since it is incomplete.
next
Select the next N complete time periods. By default, the current incomplete time period is excluded, but can also be included.
Today is 2024-01-15, and we select next 3 months
All dates in the months: Feb 2024, Mar 2024, Apr 2024. Jan 2024 is excluded since it is incomplete.
matches
Select the dates matching a date expression based on natural language, like "yesterday", "last 3 months", "7 days ago"
Please click to the link for more detailed examples
is null
Select the records that do not have date data
is not null
Select all records that have date data
The following section describes in detail the behavior of each operator on your data.
**Note:** The samples below are from a PostgreSQL database. The syntax may differ from the database you are working with.
## Is on
With the `is on` operator, you will get the data of the exact date that you selected
Example
## Between
With `between` operator, data points that lie on the boundaries will be included:
Example
In case you would like to exclude data points that lie on the boundaries, you can use the [matches](#matches) operator instead.
## Before, After
With `before` and `after` operators, any data points that lie on the date selected will be excluded:
Example
## Last, Next
When selecting these operators, you can specify the number of time periods (day, week, month, year) to look back/look forward. For example:
- Last 180 days
- Last 3 months up to today
- Last 1 year
By default, the periods are resolved into **complete time periods** by Today.
For example, Today is **2024-01-15**, then **December 2023** and **February 2024** are considered **complete months**, and January 2024 is considered **incomplete**.
The following sections will go into more details of this concept.
### Complete and incomplete time periods
For example, **last 2 months** will be resolved into "2 complete past months", which:
- **is not equivalent to the last 60 days or last 61 days**, etc., because different months have different numbers of days
- **and does not include the current month** because the current month is not complete.
For example, here we filter `last 3 months`: Today is June 15th, you will get data for March, April, and May, and **no data for June**.
Example
### How to include current partial (incomplete) period
When using `last` operator:
- For “minutes” & “hours”: The option **“Up to now”** extends the end point of your selected time range up to this very second.
- For “days”, “weeks” or longer: The option “**Up to today**” extends the end point of your selected time range up to the end of today.
:::info TIPS
You can also use [**matches**](#matches) operator to include the current partial time period.
For example, use `last 3 months - today` syntax to include both **data of the past 3 complete months** and **this partial month** up to Today.
:::
For example, assuming that it’s 20:01:04 on June 30 right now, if we filter `last 6 hours` with the “Up to now” option, we will get data from 14:00:00 to 20:01:04 on June 30.
Example
Similarly, assuming that today is June 30, 2022, if we filter the `last 3 years` with the “Up to today” option, we will get data from 2019 to the end of today - June 30, 2022.
Example
## Matches
`matches` is a special operator that can translate time expressions in **natural language** to the standard time filtering conditions in SQL.
The `matches` operator can be used for exact date, date range and time range matching. Examples of valid syntax:
- yesterday
- Monday last week
- last 2 months
- last 3 months - today
- last 3 hours to now
- next 3 years
- Dec 2018 - 1/1/2020
- 1998 - 2018
...
For more information, please refer to the dedicated [Natural Time Expression docs](/docs/datetimes/relative-dates).
:::info Note
The `matches` operator resolves condition values at runtime, not when saving the report.
:::
## Creating a Date Filter
You can create a Date filter in two ways:
1. At Filter Type, select **Field** filter, and point to a Date/ Datetime field in a model
2. Select the **Date** filter to create a manual input filter
## Mapping a Date Filter
The Date Filter can only be mapped to widget fields of Date/Datetime type:
_Only fields of Date type are available when selecting fields to map your filters to the widgets_
---
## Field filters
## Introduction
Field Filter gets data from a model field available in a Dataset. The filter's data type will depend on the data type of the field itself.
## Setting up a Field Filter
When selecting Field Type as Field, you will need to specify the path to the field you want. The path is in the form: **Dataset > Data Model > Field**.
Next, map the filter to the widgets that you want to control. Widgets created from the same dataset as the filter will be automatically mapped, but you can choose to toggle the mapping off if necessary.
### Choose the Input Type for your Filter
If the **Field value** for your Field Filter is either **Boolean** or **Text**, you can configure your Filter to accept a single value or multiple values by selecting **Single-select List** or **Multi-operator Input**, respectively.
#### Single-select List
If you choose **Single-select List**, your Filter can accept no more than one value.
You can also configure Single-select List to either:
- accept exactly one value
- or accept one value, but also allow leaving the value field empty
#### Multi-operator Input
If you choose **Multi-operator Input**, your Filter can accept a combination of multiple filter values.
## Field Filters' Properties
Field filters have some neat properties:
### Field Filters take on the source field's data type
For example, if the source field is of Date type, the filter will also have Date type, and so on.
_A filter created from the **Category** field is recognized as a Text filter, and can only be mapped to Text fields_
For more details on the properties of filters on each data type, please visit the individual pages.
### Holistics will suggest the value of field filters based on your source field's values
If the source field is of **Text** type, the filter will fetch the values from the source field to give you suggestions, along with the option of entering your values.
### Holistics will fetch 100K distinct values of the filter and cache them for 8 hours
By default, Holistics will fetch the first **100K distinct values of the field** and **cache the result for 8 hours** whenever a user clicks on the filter input box. Any [permission rules](/docs/admin/permission-system.md) for the user would be applied as well.
To refresh filter values, click on the **Refresh** button at the bottom of the suggestion box.
### They are automatically mapped to widgets
- If the filter is created from a field of dataset A, it will be automatically mapped to all widgets created from dataset A.
- When you add a **new widget** to a dashboard, if it is also created from dataset A, the filter will also be **automatically mapped to the new widget**
:::info Note
- Since the mapping is automated, please make sure that the mapping is exactly what you need. You may want to map the filter to a different field of the widget.
- It is **not required** to map the field filter to the same field in the widget. You can **map it to any field** of the same data type.
:::
---
## Filter Controls
Dashboard filters let viewers instantly focus on the data they care about: whether that's a specific region, time period, or customer segment. Instead of creating separate reports for different audiences, you build once and let viewers control what they see.
This guide focuses on **dashboard-level filter controls** that apply across multiple widgets. You can also apply filters at the block-level (pre-applied conditions on individual widgets).
## What is a Filter Control?
Filter control blocks let viewers narrow down results to see only the data that matters to them.
When someone changes a filter value (like selecting "Indonesia" for Country), all connected widgets automatically update to show only that data.
### Behind the scenes
**Example:** You create a "Country" filter and set it to "Indonesia"
- **What viewers see:** The **GMV** and **Registered User** widgets instantly show only Indonesia data
- **What happens:** The filter connects to the model field `countries.country_name` and adds the condition `WHERE countries.country_name = "Indonesia"` to all linked widgets
:::info note
You also don’t necessarily need to add a field to your results to filter on it.
For example, you can create a query that filters the Order Country in Indonesia, even though your results don't contain the Order Country field.
:::
### Mass Input of Filter Values
To filter on a large set of values, you can copy and paste a list of values into the Dashboard filter directly from a spreadsheet or clipboard.
Our mass input feature supports lists separated by: `,` `enter` `tab`.
This allows you to copy and paste a list of up to 2000 values.
## Setting up filter controls
### 1 - Add Filter Control
From the dashboard page, click **Add → Add Filter**.

### 2 - Choose the Filter Type
For each type of filter, we have separate sections for setting up step-by-step.
Read more about different [filter types](#filter-types) and [Field filters vs. manual filters](#field-filters-vs-manual-filters).

### 3 - Mapping filter controls with visualization blocks
You can select what visualization blocks you want to update in different tabs.
If you are using Field Filters, it will auto map to the visualization blocks in the same tab that come from the same dataset.

## Filter Types
In Holistics, filters are divided into two categories:
- **[Field filters](/docs/filters/field-filters.md)**: This category of filter gets information from a model field, and will take on the field's data type (Text, Number, Date, True/False)
- **Manual filters**: This category of filter is not backed by any model fields, and needs to be set up manually. This includes:
- [Text Filter](/docs/filters/text-filters.md)
- [Number Filter](/docs/filters/number-filters.md)
- [Date Filter](/docs/filters/date-filters.md)
- [True/False Filter](/docs/filters/truefalse-filters.md)
Please follow the links to the docs page for detailed information about each filter type.
## Structure of a Filter
- **Operator**: Specifies the comparison type, for example, IS, IS NOT...
- **Value**: The value of the filter. The value you can select/input depends on the [data type](/docs/data-types.md) of the filter.
- **Modifier** (optional): Only available for some of the operators in the Date filter (for example, `next`, `last` X days/months/years).
### Supported Field Types and Operators
Field type
Supported operators
Multiple values supported
Mass input supported
Text
is, is not
✅
✅
contains, does not contain, starts with, ends with, is null, is not null
❌
❌
Number
equal, not equal
✅
✅
less than, greater than, between, is null, is not null
❌
❌
Date
(all operators)
❌
❌
True/False
(all operators)
❌
❌
## Field Filters vs. Manual Filters
**Field filters** are recommended because:
- They are easy to set up
- They can be used for automatic [Drill-through](/docs/interactions/drill-through.md)
- All visualization blocks from the same dataset will be automatically mapped
**Manual filters** should be used if:
- Your dashboard widgets are created from multiple different datasets
- You want to be extra careful when setting up filters
- You don't need convenient features like filter value suggestion or automatic mapping
## Dashboard Filter Controls & Block Filters
A block filter is a filtering condition pre-applied to a block. It will not be overridden by the dashboard filter. Instead, the two filtering conditions will be combined with an AND operator.
## Managing filter mappings
You can manage filter mappings from three places:
### From filter blocks
Inside the filter block settings, you will see what blocks it maps to.

### From visualization blocks
From the visualization block, you can click on the filter icon to see what filters are applied to this block, and you can remove the mapping as well.

### From controls & filters panel
This will show all the filters available in the dashboard. You can locate the filters, edit filters, or check the mappings quickly in this centralized place.

## Hide filters
You can hide filters from dashboard viewers by dragging them outside the canvas area in **Edit mode** and saving the change. When viewers access the dashboard, these filters won't be visible.
:::danger This is not a data security feature
Hiding filters/controls is a visual feature only and does not restrict data access. Viewers can bypass hidden filters by modifying URL parameters or by inspecting the page and revealing hidden filters. For data security, we recommend using **[Row-level Permission](/docs/access-control/row-level-permission)** instead.
:::
## FAQs
### Why doesn't the default filter value reapply after refreshing the browser page?
In Holistics, modifying and submitting a filter creates a URL parameter called "fstate". This is intended for saving or sharing your dashboard with the chosen filter values.
Refreshing the page **won't reset the filter** to its default value. To revert to default filter values, you can either **go back a page** in your browser or **delete the "fstate" URL parameter**.
---
## Number Filter
## What is Number Filter?
Number Filter allows you to filter your data using a condition on a numerical value.
## Available Operators
- equal
- not equal
- less than
- greater than
- between
- is null
- is not null
## Creating a Number Filter
You can create a Number filter in two ways:
1. At Filter Type, select **Field** filter, and point to a Number field in a model
2. Select **Number** filter to create a manual-input filter
## Mapping a Number Filter
The Number Filter can only be mapped to widget fields of Number type:
_Only fields of Number type are available when selecting fields to map your filters to the widgets_
---
## Parent-child filter
## What is parent-child filter?
Parent-child filters limit the suggested values viewers can choose from in a filter (child) based on the selected values in other filters (parent)
## How to set up parent-child filter
To set up the Parent-Child filter, you need at least 2 filters: one as the Parent and one as the Child. The Parent filter limits the suggested values of the Child filter.
In Holistics, we will start at the Parent filter and link it to the Child filter. In this example, the Parent filter is **Parent Category** and the Child filter is **Category**.
1. Edit the Country filter, then go to the **Child Filters** section. Toggle on to start linking.
2. Find the filter you want to limit the suggested values (in this case: **Category**), and toggle it on.
**Notes:**
- Some filters in the Child Filters section cannot be toggled on because the filter type is not [Field](/docs/filters/field-filters). For more information, please visit the Parent-child filter relationship section.
- One Parent filter can have many Child filters and vice versa.
## How does the parent-child filter work?
When viewers open the field suggestion of the Child filter, Holistics will run a query with the condition of the **Child filter's settings and the selected values of the Parent filter**.
Then it will return the results in a drop-down for users to choose from.
Let's take the Country - City filter example.
When viewers select **Country** filter value = United States, nothing will happen, yet.
When viewers click on the **City** filter to open the drop-down, Holistics will find **all the cities in the database that belong to the United States**, then return the results as suggested values in the City filter drop-down.
### Parent-child relationship
Whether a Parent filter can limit the Child filter's values depends on 3 factors:
- The filter type of the Child filter
- The relationship of the models that contain the fields from which the Parent filter and Child filter are created
- The dataset that contains the models above
| Case no. | Parent filter type | Child filter type | Model relationship | Same dataset? | Result |
| -------- | ------------------ | ----------------- | ------------------ | ------------- | -------------------------------------------------------- |
| 1 | Field | Field | Yes | Yes | Linkable |
| 2 | Field | Field | Yes | No | Linkable |
| 3 | Field | Field | No | Yes | Linkable but Parent's values do not limit Child's values |
| 4 | Field | Field | No | No | Linkable but Parent's values do not limit Child's values |
| 5 | Field | Non-field | | | Not linkable |
| 6 | Non-field | Field | Yes | | Linkable |
| 7 | Non-field | Field | No | | Linkable but Parent's values do not limit Child's values |
### Parent-child direction
When you link a Parent to Child filter, it's a one-way direction. This means when you select a value in Parent, it will limit the Child filter's suggested values. If you select a value in Child, it will not limit the Parent filter's suggested values.
If you want to make it a two-way direction, you can link the Child to the Parent following the same process in How to set up Parent-Child filter. Please be mindful that these might lead to unwanted results for viewers.
---
## Show rows with no data when applying filter
In some cases, when exploring you apply a filter “not null” and it returns a column with a null/empty value that cannot be filtered out.
This is due to the behavior of applying both the “Filter” and “Show rows with no data” feature.
Let us explain how it works with the below example.
## Examples
We have 3 models: `Users`, `Orders` and `Products`.
The relationship between them is: Users (1) - (N) Orders (N) - (1) Products.
Suppose that you want to know how many orders each user has belong to “Jeans” products, you will add 3 fields to your exploration:
- Dimension: D1: `users.id`, D2: `products.name`
- Measure: M1: `count(orders.id)`
- The condition here is: `products.name` contains ”Jeans”
The actual result is the below image. In there, D1 has data, D2 and M1 have null data while you’ve already set the condition that D2 not null ( contains “Jeans”).
In your expected result, D2 should have data as the condition instead of showing null.
## Why did this happen?
With normal exploration, Holistics only runs **one** query **(1)** with all dimensions, measures, and filters. So, the exploration won’t include dimension combination with “empty results” because “not null” filter removed the “no data” rows.
But when enabling the “**Show rows with no data**” feature, Holistics will run 2 more queries (**(2)** and **(3)**) to fetch the dimension combination with “**empty results**”. These queries only use the filter that is directly applied to them.
As you can see, we have 3 joined queries:
**(1)** to get `users.id` - `products.name` - `count(orders.id)`
**(2)** to get `users.id` - `products.name`
**(3)** to get `users.id`
And then a big join to gather results of (1), (2), (3).
Let's see how “**Show rows with no data**”+ “**Filter**” affects 3 queries:
- Query 1 will include all filters. Therefore, the results of Query 1 contain rows that have “Jeans” purchases and have **no “empty results”**.
- For Query 2, the filter "**non-null**" applies (`products.name` contains ”Jeans”), thus it clears out the null values that are generated from the "**Show rows with no data**". The filter is applied because this query includes D2 `products.name`, which is the field that contains the filter itself.
- For Query 3, the filter condition on D2 **doesn’t affect** D1. Therefore, it would include users that have not made any “Jeans” purchases.
**To summarize**: If both **"Show rows with no data" + "Filter on Products"** are enabled
- Empty rows on the `products.name` -related joins are cleared
- Empty rows on the `users.id` that don't have relevant purchases still appear. Therefore, the final result may still show users that have not made any “Jeans” purchases and have **null** product names although we’ve already had a "**non-null**" filter on `products.name`.
## How to solve it?
Simply move the D2 on top (above the D1) or disable the `Show rows with no data` feature. In this case, when you apply the condition on D2, D2 should show the value as filtered instead of showing null.
---
## Text Filter
## What is Text Filter?
Text Filter is one of the manual-input filter types that allows you to limit your data with a text value.
## Available Operators
:::info
All of the operators are **case-insensitive**.
:::
- is
- is not
- contains
- does not contain
- starts with
- ends with
- is null
- is not null
## Creating a Text Filter
You can create a Text filter in two ways:
1. At Filter Type, select **Field** filter, and point to a Text field in a model
2. Select **Text** filter to create a manual-input filter
## Mapping a Text Filter
The Text Filter can only be mapped to widget fields of Text type:
_Only fields of Text type are available when selecting fields to map your filters to the widgets_
---
## True/False Filter
## What is True/False Filter?
True/False Filter is one of the manual-input filter types that allows you to limit your data using a boolean condition. Values available are only `true` or `false`.
## Available operators
- is
- is not
- is null
- is not null
## Creating a True/False Filter
You can create a True/False filter in two ways:
1. At Filter Type, select **Field** filter, and point to a True/False field in a model
2. Select **True/False** filter to create a manual-input filter
## Mapping a True/False Filter
The True/False Filter can only be mapped to widget fields of True/False type:
_Only fields of True/False type are available when selecting fields to map your filters to the widgets_
---
## Content Archiving
## Introduction
**Content Archiving** provides a way to hide outdated objects without permanently deleting them. This helps maintain a clean analytics environment while preserving access to historical content when needed.
Once archived, the following will happen:
- Archived objects will have a special indicator for their archival status
- On Reporting, they are hidden in folders/search (unless **Show archived items** is enabled)
- They are also listed in the [Archive page](/docs/find-organize/archive#manage-archived-objects) to centralize management
- [Holistics AI](/docs/ai/context/semantic-and-reporting-layers#use-tags-to-signal-trust) deprioritizes archived objects to ensure its answers avoid stale content.
:::info Supported objects
- **Available to**: dashboards, datasets, and data models
- **To be supported**: widgets and fields
:::
For **dashboards** specifically, once archived:
- [Data schedules](/docs/delivery/export-data#export-to-email--slack--google-sheets--sftp--telegram) and [data alerts](/docs/delivery/data-alert) are paused.
- [Dashboard auto-run on open](/docs/dashboards/settings#auto-run-on-open) is disabled.
- [Shareable links](/docs/delivery/shareable-links) and [embedded links](/docs/delivery/public-embedding) continue working as usual.
## Set up
Content Archiving uses Holistics's [tagging system](/docs/find-organize/tags). Admin will designate a tag as **Archive** and used it to mark objects as archived. To setup:
- Go to **Admin Settings > Endorsement & Archive** > **Archive settings**, select a tag
- If you don't have any tags, follow this guide to [create tags.](/docs/find-organize/tags#create-tags)
## Archive an object
Admins and analysts can archive an object by adding the archive tag to it.
## Manage archived objects
Admins and analysts can manage all archived objects through a dedicated **Archive** page on **Reporting**.
Since the Archive feature is powered by the tagging system, you can also view all associated objects with the archive tag in the `tags.aml` file on **Development**.
### Unarchive objects
Unarchiving an item removes it from the **Archive** page and show in its original location. Then, all automated features (auto-run, schedules, alerts) will be restored to their initial status.
### Delete archived objects
Deleting an archived item removes it completely from all locations in the application. This action is irreversible, so use with caution.
## Archive recommendations
Archive recommendations identify inactive objects suitable for archival. Admins and analysts can access the **Archive recommendations** panel on the **Archive** page to review and archive them.
Holistics recommends an object for archival if it has shown no activity on Reporting for **30 days or more**:
- **Dashboard:** No internal or external views (shareable links, embedded links).
- **Dataset:** No direct dataset views, and no views on canvas dashboards that use this dataset.
- **Data model, widget, field:** To be supported
You can safely inspect any item on the recommendation list before deciding to archive it, as these views don't count toward usage metrics.
## FAQs
#### 1. I couldn’t find Archive feature in our account. How can we get access to it?
This feature is available exclusively on **Holistics version 4.0** (since it’s powered by the tagging system). Please ensure you are using this version.
If not, you can [migrate your Holistics instance to version 4.0](/as-code/3.0-to-4.0-migration)
#### 2. Why can't I archive/unarchive on Reporting?
To archive/unarchive on Reporting, ensure that:
- You are an analyst or admin
- You have the **Edit** permission on the object
- Your organization has **Allow direct Dashboard Editing in Reporting** enabled
---
## Content Endorsement
## Introduction
**Content Endorsement** provides your organization a powerful way to mark trusted and reliable content and data. This helps users quickly identify the best information available for wider organizational use.
## High-level mechanism
Once a tag is marked as endorsed, all objects associated with that tag will have a ✅ icon next to it. Endorsed items are prioritized over normal items in Reporting Search and in [Holistics AI answers](/docs/ai/context/semantic-and-reporting-layers#use-tags-to-signal-trust), given the same keywords.
## How-to
### Set up
Content Endorsement uses Holistics's [tagging system](/docs/find-organize/tags). Admin will designate a tag as **Endorse** and used it to mark objects as endorsed. To setup:
- Go to **Admin Settings > Endorsement & Archive** > **Endorsement settings**, select a tag
- If you don't have any tags, follow this guide to [create tags.](/docs/find-organize/tags#create-tags)
### Endorse an object
Analysts and admins can endorse an object by adding the endorsed tag to it.
## FAQ
#### 1. I couldn’t find Content Endorsement feature in our account. How can we get access to it?
This feature is available exclusively on **Holistics version 4.0** (since it’s powered by the tagging system). Please ensure you are using this version.
If not, you can [migrate your Holistics instance to version 4.0](/as-code/3.0-to-4.0-migration)
#### 2. Why can't I endorse/unendorse on Reporting?
To endorse/unendorse on Reporting, ensure that:
- You are an analyst or admin
- Your organization has **Allow direct Dashboard Editing in Reporting** enabled
---
## Search & Bookmark
## Introduction
[**Search**](/docs/find-organize/search-and-bookmark#search) and [**Bookmark**](/docs/find-organize/search-and-bookmark#bookmark) help you quickly find and save your essential dashboards, and datasets. This ensures your team can always find what they need fast, especially when the number of objects created has grown significantly.
## Search
**Search** your workspace directly with **keywords** or [**tags**](/docs/find-organize/tags). By default, keywords are matched against both the **title and description** for smarter results. You can always narrow the scope to the title-only search if needed.
You can further refine your results using filters:
- **By types:** Dashboard, Report/Widget, Dataset, Folder
- **By tags**
Note that items with certain tags behave differently:
- [Endorsed items](/docs/find-organize/content-endorsement) are prioritized to show on top.
- [Archived items](/docs/find-organize/archive) are hidden by default, unless **Show archived items** is enabled.
## Bookmark
**Bookmark** lets you save important items for easy access later.
We offer two bookmarking types: [**Favorites**](/docs/find-organize/search-and-bookmark#favorites) (for personal use), and [**Pin dashboards to Homepage**](/docs/find-organize/search-and-bookmark#pin-dashboards-to-homepage) (for organization-wide visibility).
### Favorites
Any items marked with a ⭐ icon will be saved in your **Favorites** section for quick access.
### Pin dashboards to Homepage
Pinning dashboards to the homepage allows admins to prominently display crucial dashboards for all users. This ensures broader visibility and easier access to key insights throughout your organization.
**How-to:** Locate the ellipsis (…) button in any dashboard, and select the option **Pin to Org Homepage.**
### Permissions and visibility
1. **Pinning permissions:** Currently, only users with the **Admin** role can pin and unpin dashboards to the Homepage.
2. **Workspace restrictions:** Only dashboards located in [Public Workspace](/docs/admin/permission-system#public-workspace) can be pinned. If you want to pin [Shared](/docs/admin/permission-system#shared-with-me) or [Private](/docs/admin/permission-system#personal-workspace) dashboards, please move them to the Public Workspace.
3. **User access:** Not all users are allowed to see the pinned dashboards (it depends on their access permission). For example, if a pinned dashboard is only shared with Singapore user group, users from other region groups **won’t see it** displayed on their Homepage.
If you’re pinning a dashboard that isn’t shared with all users, the system will prompt you with the **Edit User Access** modal. This allows you to adjust user permissions if necessary.
4. **Pinned dashboard display order:** Recently pinned dashboards will show up first on the left side of the Homepage.
---
## Tags
## Introduction
Tags help you efficiently categorize analytics content, allowing users to quickly discover related items through meaningful categories and labels.
## High-level concept
Tags are a type of object metadata that helps organize and categorize your analytics content. Tags can be assigned to dashboards, datasets, and data models.
Key characteristics:
- **Unique name:** Each tag name must be unique and is case insensitive. Renaming a tag to an existing name will [merge these two tags](/docs/find-organize/tags#merging-tags).
- **Nested structure**: You can create nested tags up to one level deep (e.g., `status/done`).
- **Enrich details**: You can make your tags more meaningful and visually distinctive by adding descriptions and colors.
## How-to
### Create tags
:::tip quick start
Admin can follow the tutorial [**here**](/docs/find-organize/tags#2-how-to-set-up-your-tagging-system) to automatically set up the organization tagging system.
:::
You can create tags in two ways:
- **Centralized in `tags.aml`**: All tags are centralized in the `tags.aml` file, located in Development > settings folder. Using the Code or Visual editor, you can:
- Create, view, edit (name, description, and color), and delete tags
- Manage all data models, datasets, and dashboards associated with a specific tag
- **Directly on objects:** Create tag by adding them directly to objects without pre-defining them.
### Add tags to an item
Tags can be assigned to (or removed from) items in both environments:
- **In Development**: You can either assign existing tags to an item, or create a new one.
- **In Reporting**: You can only assign existing tags to an item.
Any user with edit permissions on a specific item can assign and remove tags from that item.
#### Supported objects
- Data model
- Dataset
- Dashboard
- Widget _(to be supported)_
- Field _(as-code only)_
## Syntax
**`tags.aml` file**: This file centralized the as-code definition of all tags.
```aml {title="settings/tags.aml"}
// Create a new tag in tags.aml
Tag {
name: 'Sales'
description: 'Relates to Sales team.' // Optional
color: '#C53BFF7' // Optional
}
Tag {
name: 'status/WIP' // Nested tag is supported
description: "Don't use these items." // Use double quote if the content includes quote marks.
}
```
**Object’s file (e.g., `dashboard.page.aml`)**: In each tagged object's file, tags are reference as-code.
```aml {title="dashboard.page.aml"}
// Add a tag to a data model
@tag('marketing', 'high-level metrics')
Model facebook_ads {
type: 'table'
label: 'Facebook Ads Data'
description: ''
data_source_name: ''
// Add a tag to a field
@tag('verified')
dimension id {...}
}
// Add a tag to a dashboard
@tag('status/WIP', 'verified')
Dashboard ecommerce {
block v1: VizBlock {
}
}
```
## Special cases
### Ad hoc tags
Ad hoc tags are tags **created directly on an item, using the Code Editor.** These tags are visible in the Visual Editor but not available in the code file of the `tags.aml` file. They display with a dashed border to visually distinguish them from normal tags.
Once you add metadata (color, description) to these tags, they will be auto-added to `tags.aml` and become a normal tag.
### Merging tags
You can merge two existing tags **by renaming one tag to match another in the `tags.aml`** file. This is useful when you have duplicate or similar tags that should be consolidated.
After merging:
- All items previously tagged with tag A are auto-reassigned to tag B
- The merged tag retains all metadata and characteristics of the original tag B
- Tag A is removed from the system
For example, if you rename “Marketing Dept” to “Marketing,” all dashboards and datasets tagged with “Marketing Dept” will automatically be retagged as “Marketing".
### Tags of extended items
When you extend an item from a master item using [AML Extend](/reference/aml/extend), the extended item automatically inherits all tags from its master. However, if you add new tags directly to the extended item using code, these new tags will override the inherited tags.
For example, imagine you have a master dashboard called “**Original Ecommerce**” with two tags `status/WIP` and `high-level metrics` .
When you create an extended dashboard named “**Extend Ecommerce**” from “**Original Ecommerce**”, it will initially inherit these two tags:
But, if you then add a new tag, `dept/Sales`, directly to the “**Extend Ecommerce**” dashboard, it will only have the `dept/Sales` tag. The inherited tags (`status/WIP` and `high-level metrics`) will be replaced.
## FAQs
#### 1. We’re on version 3.0. How can we use Tags features?
Tagging System is available exclusively on **Holistics version 4.0**. Please ensure you are using this version.
If not, you can [migrate your Holistics instance to version 4.0](/as-code/3.0-to-4.0-migration).
#### 2. How to set up your tagging system?
**Option 1: Auto setup** _(Only available to admins, and tenants without the `tags.aml` file)_
Admins can automatically set up the organization tagging system with a default tag list and configure the two special tags ([endorse](/docs/find-organize/content-endorsement)/[archive](/docs/find-organize/archive)).
**Option 2: Manual setup** _(Available to analysts and admins)_
- Go to Development, find your `tags.aml` file in **settings** folder or create it yourself
- Copy our recommended list of tags into your `tags.aml` file
- Publish to save your changes
```
// #############################################################
// 📘 TAGGING SYSTEM OVERVIEW
//
// This section defines a reusable tagging system to help BI teams manage:
// - 🎯 System: Special tags with automated behaviors (endorsement & archival)
// - 📊 Status: Track content maturity and maintenance needs
// - 🧑💼 Owner: Identify which team maintains this content
//
// 🎯 PURPOSE:
// Tags help BI teams communicate quality, lifecycle, and ownership directly
// inside the reporting tool. It's part of a best practice for dashboard governance.
//
// 🛠️ SETUP INSTRUCTIONS:
// For more information, visit our public docs:
// - https://docs.holistics.io/docs/find-organize/tags
// - https://docs.holistics.io/docs/find-organize/content-endorsement
// - https://docs.holistics.io/docs/find-organize/archive
//
// #############################################################
// ---------------------
// 🎯 SYSTEM TAGS
// Special tags with automated platform behaviors
// ---------------------
Tag {
name: 'Endorsed'
color: '#00ff00'
description: 'The item is highly recommended.'
}
Tag {
name: 'Archived'
color: '#808080'
description: 'The item is no longer maintained and only preserved for historical or reference purposes.'
}
// ---------------------
// 📊 STATUS TAGS
// Track content maturity and maintenance needs
// ---------------------
Tag {
name: 'Status/Active'
color: '#00ffff'
description: 'Indicates the item is finalized, maintained, and currently in use for official purposes.'
}
Tag {
name: 'Status/Draft'
color: '#ffa500'
description: 'Indicates the item is in development and not yet finalized for official use.'
}
Tag {
name: 'Status/Error'
color: '#ff0000'
description: 'Indicates the presence of issues or inaccuracies that require correction before use.'
}
// ---------------------
// 🧑💼 OWNER TAGS
// Identify which team or department owns and maintains this content
// ---------------------
Tag {
name: 'Owner/Sales'
color: '#800080'
description: @md Maintained by the **Sales team**.
Revenue, pipeline, and customer acquisition metrics.;;
}
Tag {
name: 'Owner/Marketing'
color: '#800080'
description: @md Maintained by the **Marketing team**.
Campaign, channel, and brand performance.;;
}
Tag {
name: 'Owner/Finance'
color: '#800080'
description: @md Maintained by the **Finance team**.
Revenue, costs, budgets, and forecasts.;;
}
```
#### 3. What are some best practices for using tags?
See [Tagging best practices](/docs/tags/tips) for tips to build your own tags.
#### 4. Can I tag a field?
Yes, but with limitations. Field tagging is currently supported as-code only, which means:
- **No GUI support**: You cannot assign, remove, or view field tags through the interface (e.g., in the dataset explore view). Tags must be added directly in the field's AML code.
- **No auto-update on tag changes**: If you rename a tag in `tags.aml`, field tags referencing the old name are not automatically updated. You need to update them manually in each field's code.
- **AI can read field tags**: Despite the GUI limitations, AI features can still read and process field-level tags.
---
## What Data Consumers can do with Holistics?
If you are a sales, marketing, product... team member/leader, there is a high chance that you are a **data consumer** that uses data to make well-informed decisions daily, but are less technical-inclined. In Holistics, data consumers can be assigned **Explorer** or **Viewer** role.
Let's figure out what data consumers can achieve with Holistics!
## Self-serve your data needs with Dataset
Sometimes it feels frustrating that the analysts cannot give you a simple CSV export, or that cohort analysis within this morning. The most common reason is your organization's data team is also flooded with requests coming from every other department.
Understand this problem, Holistics allows **Explorers** to answer their questions with cleaned datasets prepared by the data team, with a familiar drag-and-drop interface. You can freely combine fields and measures to aggregate data into different levels, or build complicated dashboards without knowing SQL.
## Safely explore current dashboards
With the Exploration feature, Holistics allows Explorers to dig deeper into charts and tables instead of just viewing them passively. You can freely filter data, change fields and measure combinations, or tweak the widget's visualization in the Exploration pane without affecting the original widgets.
## Get data delivered directly to your preferred channels
Sometimes you do not want to spend too much time exploring - instead, a static snapshot of the dashboard is good enough for you to make crucial decisions.
Holistics provides easy-to-set-up email, Slack and Google Sheet scheduling features that can deliver data to everyone in their preferred channels.
## Collaborate with your colleagues
With Holistics commenting system, you can add a personal comment or start a conversation with colleagues about a dashboard on the dashboard itself.
---
## What Data Builders can do with Holistics
If you are a data analyst, data engineer or data leader, you are **data builders** who can make full use of Holistics's functions and abilities. In Holistics, data builders' can be assigned **Admin** and **Analyst** roles. Here are what data builders can achieve with Holistics:
## Consolidate data from multiple sources
Holistics lets you unify and model your data from different sources, using SQL-based data models to generate analytics. You can just directly pull data from various data sources, such as **CSV, Google Analytics, Google Spreadsheet** or **production data**, etc. into your **SQL database** to start analyzing.
## Map business logic to data logic
With [Holistics's Data Modeling layer](https://docs.holistics.io/docs/data-modeling#holisticss-data-modeling-layer), you can map business logic to your physical data so the end-users can understand and explore data easily with minimal help from the data team. This can help reducing data miscommunications by offering a single source of truth for everyone to understand all different data definitions, from what a metric means in the business context to how it's being calculated.
## Transform your data
Data builders can build reusable data components from the imported raw data, perform complex data cleaning and standardize them with the Query Model. Every transformation in Holistics is run against your Data Warehouse, so you can leverage both its storage and processing power.
## Manage your data pipeline
Holistics provides a simple approach to help you automate and maintain the company's full data pipeline and data sources without needing to trouble data engineers. You can maintain a scaleable data workflow with a unified and high-level view of your organization's data.
## Enable non-technical users to do their own analysis
No longer dread the constant stream of simple data pull requests nor bombarded by business users for ad-hoc analytics. After producing clean and well-documented data models, you can combine them into Datasets and that allows self-service analytics for business users.
This leads to a win-win situation: data consumers do not have to wait for you all the time, while you have more time to do analytics projects that deliver higher values to the organization.
---
## Friendly Loading Messages
# What is Friendly Loading Messages
Friendly Loading Message will be showed when you load a Dashboard, Widget or Report
# How to turn on/off Friendly Loading Messages
By Default, the Friendly Loading Messages feature is enabled.
If you want to disable this feature, go to [General Settings](https://secure.holistics.io/manage/settings#form-general)
and toggle off the Friendly Loading Messages feature.
---
## From Tableau to Holistics
:::info Version scope
This comparison was last reviewed in June 2026. It compares Holistics with the classic Tableau experience in **Tableau 2026.2**: Tableau Desktop and Tableau Cloud/Web Authoring, including Tableau data sources, worksheets, dashboards, and publishing to Tableau Cloud or Tableau Server.
It **does not** use Tableau Next as the main baseline. Tableau Pulse, Tableau Next, and Tableau Semantics are mentioned only where they affect the conceptual comparison.
:::
## Concept mapping
If you are coming from Tableau, the biggest shift is that Holistics treats the semantic layer as a first-class part of the BI workflow.
This page maps familiar Tableau concepts to Holistics and gives you a practical migration path.
Core mapping:
- Tableau **data source** → Holistics **data source + models + dataset**
- Tableau **worksheet/view** → Holistics **exploration or visualization block**
- Tableau **dashboard** → Holistics **Canvas dashboard**
| Tableau artifact | Holistics equivalent | Key difference |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| **Workbook** | Models, datasets, explorations, and [Canvas dashboards](/docs/dashboards/) | Tableau packages these together. Holistics keeps them as separate reusable project objects. |
| **Connector / connection** | [Data source](/docs/connect/) | Tableau supports many file, app, and database connectors. Holistics focuses on SQL data sources. |
| **Data source** | [Data source](/docs/connect/) + [model](/docs/data-model) + [dataset](/docs/datasets) | Tableau data sources can hold relationships, calculations, metadata, and extracts. Holistics splits these layers. |
| **Extract / Tableau Prep** | Query cache, persisted model, dbt/warehouse SQL, or [query model](/docs/query-models) | Holistics usually pushes work to the warehouse instead of copying data into a BI engine. |
| **Logical / physical table** | [Model](/docs/data-model), [query model](/docs/query-models), or upstream dbt/SQL model | Holistics models sit on top of warehouse tables or SQL queries. |
| **Relationship / join canvas** | [Dataset relationship](/docs/relationships) | Holistics declares relationships in datasets and compiles joins at query time. |
| **Calculated field** | [Dimension](/docs/model-fields#adding-new-dimensions), [measure](/docs/model-fields#adding-new-measures), or [metric](/docs/metrics-in-datasets) | Model reusable business logic once; keep one-off display logic in the chart. |
| **Worksheet / view** | [Exploration](/docs/data-exploration) or visualization block | Tableau builds worksheets first. Holistics builds charts from governed datasets. |
| **Shelves / Marks card** | Visualization fields, filters, and settings | Similar drag-and-configure flow, but fields resolve through the Holistics dataset. |
| **Dashboard** | [Canvas dashboard](/docs/dashboards/) | Both compose charts and controls. Holistics dashboards are code-reviewable AMQL. |
| **Parameter** | [Parameter field](/docs/modeling/param-fields) | Both carry user input into queries and field definitions to make charts dynamic. |
| **Action** | [Dashboard action](/docs/actions) | Both trigger row-level workflows. Holistics actions currently target Data Table visualizations via Go To URL. |
| **Pulse / Next semantic model** | [Metric](/docs/metrics-in-datasets), [Holistics AI](/docs/ai), and [semantic layer](/docs/modeling/) | Not the baseline for this page, but closest to Holistics's semantic-layer direction. |
## Migrating from Tableau to Holistics
A Tableau migration has two jobs:
- Move reusable data logic out of Tableau workbooks/data sources.
- Rebuild charts and dashboards on top of a governed Holistics semantic layer.
Start with shared definitions, not a worksheet-by-worksheet copy.
:::tip
Do not migrate every Tableau object literally. Hidden helper worksheets, one-off groups, and layout workarounds often become cleaner as Holistics metrics, dimensions, filters, or interactions.
:::
:::info Warehouse-first migration
Holistics works with SQL databases. If a Tableau workbook depends on extracts, file uploads, Tableau Prep outputs, or application connectors, first land that data in your data warehouse.
:::
### 1. Inventory Tableau content
Start with dashboards people actually use. For each workbook, capture:
- Data sources and live/extract mode.
- Relationships, joins, unions, and custom SQL.
- Visible and hidden worksheets.
- Calculated fields, parameters, sets, groups, bins, table calculations, and level-of-detail calculations.
- Dashboard filters, actions, tooltips, subscriptions, alerts, and permissions.
Use Tableau Catalog or the Metadata API if available. Still review important dashboards manually; usage context often reveals which calculations are truly business-critical.
### 2. Move data into data warehouse
Before modeling in Holistics, make sure the data is available in a SQL data source:
- Move extract-backed tables, file uploads, and app-connector data into your data warehouse.
- Reuse existing warehouse tables if the Tableau workbook already points to a SQL database.
- [Connect Holistics to the data source](/docs/connect/) and confirm schema access.
### 3. Move prep upstream
Move production transformations out of Tableau where possible:
- Use dbt or warehouse SQL jobs for shared transformations.
- Use [table models](/docs/table-models) for reporting-ready warehouse tables.
- Use [query models](/docs/query-models) for lightweight reusable SQL on top of existing tables or models.
If you already use dbt, keep transformations there and connect Holistics to the dbt-modeled tables. See [when to model in dbt vs. Holistics](/docs/dbt-integration/when-to-model-in-dbt-vs-holistics) for the split.
### 4. Model and curate datasets
For the semantic layer:
- Create a [data model](/docs/data-model) for each source table or query output.
- Add labels, descriptions, field types, formatting, hidden fields, dimensions, and measures.
- Choose one business-friendly name when multiple Tableau workbooks renamed the same field differently.
- Define [relationships](/docs/relationships) inside [datasets](/docs/datasets).
If a Tableau workbook used field formatting, apply the same formatting on the Holistics field so values read consistently across dashboards:
### 5. Translate calculations
Map each Tableau calculation by intent:
| Tableau pattern | Holistics destination |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Row-level logic | [Custom dimension](/docs/model-fields#adding-new-dimensions) |
| Simple aggregation | [Measure](/docs/model-fields#adding-new-measures) or dataset [metric](/docs/metrics-in-datasets) |
| Reusable KPI | Dataset [metric](/docs/metrics-in-datasets), usually in AQL |
| Cross-model, period comparison, nested aggregation, or LOD logic | Dataset metric or dimension in AQL |
| Parameter-driven logic | [Parameter field](/docs/modeling/param-fields), dashboard control, or modeled pattern |
| One-off grouping or display logic | Visualization settings, or a modeled dimension if reused |
For example, a Tableau conditional calculated field can become a custom dimension in Holistics. Write the Holistics expression in the SQL dialect of your database; the example below uses `case when` syntax.
**In Tableau:**
**In Holistics:**
If the logic defines a business concept, model it once. If it only changes one chart, keep it close to the chart.
### 6. Rebuild charts and dashboards
For charts:
- Rebuild important Tableau worksheets as Holistics [explorations](/docs/data-exploration) or visualization blocks.
- Map fields, filters, sorting, and visual encoding.
- Convert hidden helper worksheets into metrics, dimensions, filters, or interactions where possible.
For dashboards, use [Canvas dashboards](/docs/dashboards/) and map common Tableau features like this:
| Tableau dashboard feature | Holistics equivalent |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Dashboard filter | [Filter block](/docs/filters/) |
| Worksheet on a dashboard | Visualization block |
| Text, image, or explanatory content | Text block with Markdown |
| Dashboard action / navigation | [Dashboard interaction](/docs/interactions/interact-with-canvas-dashboard), [drill-through](/docs/interactions/drill-through), tabs, or links |
| Date granularity control | [Date drill](/docs/interactions/date-drills) or modeled date dimensions |
| Shared chart reused in multiple dashboards | [Reused block](/docs/reused-blocks) |
Use this step to simplify old helper sheets and layout workarounds. Canvas dashboards let you rebuild the final user experience more directly.
### 7. Validate and publish
Before replacing the Tableau dashboard:
- Compare key numbers side by side.
- Use the same filters, date range, timezone, extract freshness, and row-level security assumptions.
- Check common mismatch sources: hidden filters, stale extracts, timezone handling, and worksheet-scoped calculations.
- Publish through [Git version control](/docs/git-version-control/) and [dev/prod mode](/docs/development/dev-prod-mode).
- Recreate permissions, row-level rules, scheduled deliveries, share links, and embedded analytics surfaces.
### Example: Sales Performance dashboard
Suppose a Tableau workbook has:
- **Data source:** `orders`, `order_items`, and `products`, using an hourly extract.
- **Worksheets:** Sales by month, top products, and fulfillment status.
- **Calculated field:** `if [status] = 'delivered' then 'Delivered' else 'Open' end`.
- **Dashboard action:** click a product to drill into order details.
In Holistics, migrate it as:
- Land extract-backed tables in the warehouse or dbt.
- Create `orders`, `order_items`, and `products` models.
- Define reusable fields and metrics once.
- Curate a Sales dataset with the right relationships.
- Rebuild the worksheets as visualization blocks on a Canvas dashboard.
- Use drill-through for the product-to-orders detail flow.
Example model fields:
```aml title="orders.model.aml"
Model orders {
type: 'table'
table_name: 'analytics.orders'
data_source_name: 'warehouse'
dimension fulfillment_status {
label: 'Fulfillment Status'
type: 'text'
definition: @sql
case
when {{ #SOURCE.status }} = 'delivered' then 'Delivered'
else 'Open'
end
;;
}
dimension amount {
label: 'Amount'
type: 'number'
definition: @sql {{ #SOURCE.amount }};;
}
measure total_sales {
label: 'Total Sales'
type: 'number'
definition: @sql sum({{ amount }});;
aggregation_type: 'custom'
}
}
```
---
## Coming from other BI tools
Already comfortable with another BI platform? These guides translate the concepts you know into the Holistics way of doing things, then walk you through migrating your existing work.
## Migration guides
Pick the tool you're coming from to see how its concepts map to Holistics.
Map LookML views, explores, dimensions, and measures to Holistics, then migrate your models and dashboards.
See how Power BI concepts line up with Holistics and where the two platforms differ in philosophy.
Translate Tableau workbooks, data sources, and calculated fields into Holistics models, datasets, and dashboards.
---
## Conceptual Differences
If you come from Looker background, learn the difference and similarities between Looker and Holistics.
## Views and Explores (Looker) vs. Models and Datasets (Holistics)
A single **Looker view corresponds to a model in Holistics**, while a **Looker explore aligns with a dataset in Holistics**. However, there are certain nuances that prevent them from being exactly equivalent on a conceptual level, which we will clarify with examples later on.
Here's how Looker concepts map to the equivalent Holistics concepts:
| Looker file | Holistics equivalent | Purpose & difference |
|------------------------|-------------------------|------------------|
| [Looker view](https://cloud.google.com/looker/docs/lookml-terms-and-concepts#view) (.view.lkml) | [Holistics model](/docs/data-model) (.model.aml) | Similar purpose, different syntax. Looker views (Holistics models) are your basic building blocks for defining business logic. Both represent a database table or a derived table. |
| [Looker model](https://cloud.google.com/looker/docs/lookml-terms-and-concepts#model) (.model.lkml) | [Holistics dataset](/docs/datasets) (.dataset.aml) | Both represent an abstraction on top of a database to enable self-service exploration. Holistics datasets are more flexible - they don't require a root view and use automatic join paths. |
| [Looker explore](https://cloud.google.com/looker/docs/lookml-terms-and-concepts#explore) (inside model file) | [Holistics dataset](/docs/datasets) | Looker defines `explore` as a `view` user can query. Unlike Looker, in Holistics, user can query from any model in a dataset. |
| [Looker project](https://cloud.google.com/looker/docs/what-is-lookml#lookml_projects) | [Holistics module](/reference/aml/module) | Holistics only contain one "project" but can segregate code through use of `module`. |
**Looker view to Holistics model:**

**Looker explore to Holistics dataset:**

## Dynamic Root Models vs. Root Models
:::info TLDR
In Looker, your explores always start from a root view, generating JOINs from there. In Holistics, the starting point is dynamic, chosen based on the fields selected.
:::
Looker's explore feature is a SQL generation interface that creates a series of JOINs based on the fields you select. The first table in the FROM clause of the generated query represents the initial view in Looker’s explore (or in Holistics, we call it root model).

This means that, even when you only select fields in `users` view (via Field Picker UI), Looker will always generate the SQL starting from the root model `events` to `users` via your specified joins.
In Holistics, the table in the from clause is dynamic based on which fields are selected in the our Explore UI
## Relationships over Joins
:::info TLDR
In Looker, you define explicit JOIN clauses in your explores. In Holistics, you define relationships between models, and Holistics automatically generates optimal join paths
:::
A key distinction between Looker and Holistics lies in how they handle joins. Holistics uses relationships to automatically generate appropriate joins, whereas Looker requires users to pre-define the joins in advance within their explores.

For example, this Looker explore:
```tsx
explore: orders {
join: users {
sql_on: ${orders.user_id} = ${users.id} ;;
relationship: many_to_one
}
}
```
Becomes this in Holistics:
```tsx
Dataset ecommerce {
models: [orders, users]
relationships: [
relationship(orders.user_id > users.id, true) // The '>' indicates many-to-one
]
}
```
## Data Source Connection
In Looker and Holistics, data source connections work differently, affecting how you develop and preview your models.
### Looker's Approach
Looker requires you to define connections at the model file level only. This means all views and explores within a model must use the same database connection.
```tsx title="Looker Model file"
connection: "warehouse"
include: "views/*.view"
explore: orders {
join: users { ... }
}
explore: products {
join: categories { ... }
}
```
When developing in Looker, you cannot preview data directly after creating a new dimension or measure in a view. The process requires:
1. First defining the view
2. Including that view in a model file
3. Creating an explore for that view in the model file
4. Navigating to the Explore page to preview your changes
This creates a longer feedback loop during development.
### Holistics' Approach
Holistics offers more flexibility by allowing you to specify data source connections at either the dataset or model level:
```tsx title="Holistics"
Dataset ecommerce {
data_source_name: 'warehouse'
models: [orders, products]
}
Model orders {
data_source_name: 'warehouse'
}
Model products {
data_source_name: 'warehouse'
}
```
This flexibility enables immediate data previewing in the context of your data model. When you add a new dimension or measure to a model, you can instantly preview its output, creating a faster development cycle.
### Key Differences
| Feature | Looker | Holistics |
|---------|--------|----------|
| **Connection definition** | ❌ In Model file only | ✅ Both dataset and model level |
| **Preview capability** | ❌ Requires explore setup | ✅ Immediate in-context preview |
| **Development workflow** | ❌ Multi-step process | ✅ Direct feedback loop |
This approach in Holistics leads to a more streamlined modeling experience with faster iteration cycles.
---
## Migrating Looker Dashboards to Holistics
## High-level Overview
### Looker Dashboard Types vs Holistics Dashboard
Looker has two types of dashboards:
- **LookML Dashboard**: Version-controlled dashboards defined in code, managed by LookML developers
- **User-defined Dashboard**: UI-based dashboards created via drag-and-drop in Personal or Shared folders
Holistics takes a unified approach with a single dashboard type that supports both UI and code editing modes. This eliminates the need to convert between different dashboard types - users can build visually and the code is automatically generated.
### Component Mapping
| Looker Component | Holistics Equivalent | Notes |
|-----------------|---------------------|--------|
| Query Tile | Visualization Block | Created from explores/datasets |
| Look-linked Tile | N/A | Holistics doesn't have standalone reports |
| Text Tile | Text Block | For adding text, markdown content |
| Extension Tile | N/A | For custom extensions |
| Dashboard Filters | Filter Block | For filtering dashboard data |
### Dashboard Settings Comparison
| Feature | Looker | Holistics | Notes |
|---------|---------|-----------|--------|
| Timezone Adjustment | ✅ | ✅ | Both platforms support |
| Run on Load | ✅ | ✅ | Load data automatically on dashboard open |
| Full-screen Mode | ✅ | ✅ | Expand visualizations to full screen |
| Auto-refresh | ✅ | Coming soon | Automatic data refresh intervals |
| Default Filter View | ✅ | N/A | Holistics uses inline filters |
| Filter Panel Location | Fixed positions | Flexible | Place filters anywhere in layout |
### Visualization Type Mapping
When migrating from Looker to Holistics, you'll need to map your existing visualizations to their Holistics equivalents. This table provides a comprehensive mapping between Looker visualization types and their Holistics counterparts:
| Looker Visualization | Holistics Equivalent | Notes |
|----------------------|----------------------|-------|
| [Column Chart](https://cloud.google.com/looker/docs/column-options) | [Column Chart](/docs/charts/column-chart) | Standard vertical bar visualization for category comparison |
| [Bar Chart](https://cloud.google.com/looker/docs/bar-options) | [Bar Chart](/docs/charts/bar-chart) | Horizontal bar visualization for category comparison |
| [Line Chart](https://cloud.google.com/looker/docs/line-options) | [Line Chart](/docs/charts/line-chart) | Ideal for time series and trend analysis |
| [Area Chart](https://cloud.google.com/looker/docs/area-options) | [Area Chart](/docs/charts/area-chart) | Shows volume and cumulative values over time |
| [Pie Chart](https://cloud.google.com/looker/docs/pie-options) | [Pie/Donut Chart](/docs/charts/pie-chart-donut-chart) | Visualizes part-to-whole relationships |
| [Scatter Plot](https://cloud.google.com/looker/docs/scatter-options) | [Scatter Chart](/docs/charts/scatter-chart) | Shows correlation between two numeric variables |
| [Table](https://cloud.google.com/looker/docs/table-options) | [Table](/docs/charts/table) | Tabular data presentation with formatting options |
| [Single Value](https://cloud.google.com/looker/docs/single-value-options) | [KPI Metric](/docs/charts/metric-kpi) | Displays a single metric with optional comparison |
| [Box Plot](https://cloud.google.com/looker/docs/boxplot-options) | ❌ Not natively supported yet | Available through Holistics [Custom Chart](/docs/charts/custom-charts) |
| [Waterfall Chart](https://cloud.google.com/looker/docs/waterfall-options) | [Custom Waterfall Chart](/docs/charts/custom-charts/library/waterfall-chart) | Shows sequential contribution to a final value |
| [Funnel Chart](https://cloud.google.com/looker/docs/funnel-options) | [Funnel Chart](/docs/charts/pyramid-chart-funnel-chart) | Visualizes sequential process or conversion rates |
| [Word Cloud](https://cloud.google.com/looker/docs/word-cloud-options) | [Word Cloud](/docs/charts/word-cloud) | Text visualization with size indicating frequency |
**Additional Holistics Visualizations**
Holistics offers several visualization types that aren't available in Looker:
- [Metric Sheet](/docs/charts/metric-sheets) - Compact display of multiple KPIs in a grid layout
- [Pivot Table](/docs/charts/pivot-table) - Advanced table with nested rows and columns for complex data analysis
- [Gauge Chart](/docs/charts/gauge-chart) - Visual representation of a metric within a defined range
- [Bubble Chart](/docs/charts/bubble-chart) - Enhanced scatter plot with third dimension shown by bubble size
- [Radar Chart](/docs/charts/radar-chart) - Multi-variable data visualization on axes starting from the same point
When migrating dashboards, you'll typically map most Looker visualizations directly to their Holistics equivalents, while potentially enhancing your dashboards with these additional visualization types.
## Step-by-Step Migration Tutorial
### Step 1: Preparation
1. Open your source Looker dashboard
2. Create a new dashboard in Holistics
3. List all components to migrate:
- Visualization tiles
- Text content
- Filters
- Dashboard settings
### Step 2: Migrate Visualizations
1. For each query tile:
```tsx
Dashboard your_dashboard {
title: 'Dashboard Title'
//highlight-start
block block_name: VizBlock {
label: 'Visualization Label'
viz: ChartType { // ChartType can be BarChart, DataTable, LineChart, PieChart, ScatterPlot, Table, etc.
dataset: dataset_name
// other visualization settings
}
}
//highlight-end
}
```
2. For each text tile:
```tsx
Dashboard your_dashboard {
title: 'Dashboard Title'
//highlight-start
block t5: TextBlock {
content: @md Your markdown content here;;
}
//highlight-end
}
```
### Step 3: Set Up Filters
```tsx
Dashboard your_dashboard {
title: 'Dashboard Title'
block block_name: VizBlock { }
//highlight-start
block block_name: FilterBlock {
label: 'Filter Label'
type: 'field'
source: FieldFilterSource {
dataset: dataset_name
field: r(model_name.field_name)
}
default {
operator: 'is'
value: []
}
}
//highlight-end
}
```
### Step 4: Configure Dashboard Settings
```tsx
Dashboard your_dashboard {
title: "Your Dashboard Title"
description: "Dashboard description"
//highlight-start
settings {
timezone: 'America/Los_Angeles'
autorun_on_open: false
cache_duration: 10
}
//highlight-end
}
```
### Step 5: Test and Validate
1. Compare visualization outputs
2. Verify filter functionality
3. Test dashboard loading performance
4. Check mobile responsiveness
5. Validate user permissions
## Common Migration Patterns
This section demonstrates how to migrate common dashboard components from Looker to Holistics.
### 1. Basic Dashboard Structure
```yml title="Looker Dashboard Structure"
- dashboard: sales_overview
title: "Sales Overview"
description: "Sales performance metrics and trends"
enable_viz_full_screen: true
layout: tile | static | grid | newspaper
refresh: "1 hour"
auto_run: true
width: 1500 # For layout: static dashboards
```
```tsx title="Holistics Dashboard Structure"
Dashboard sales_overview {
title: "Sales Overview"
description: "Sales performance metrics and trends"
settings {
autorun_on_open: true
}
view: CanvasLayout {
width: 1560 // Dashboard width in pixels
height: 1080 // Dashboard height in pixels
}
}
```
Note:
* Holistics uses a simpler dashboard configuration model with Canvas layout by default. While Looker supports multiple layout types (`tile`, `static`, `grid`, `newspaper`), Holistics provides a flexible canvas where blocks can be freely positioned.
* Features like refresh intervals are not currently supported.
### 2. Filter Configuration
```yml title="Looker Filter Configuration"
- dashboard: sales_overview
crossfilter_enabled: true
filters:
- name: order_status
title: "Order Status"
type: field_filter
model: sales
explore: orders
field: orders.status
default_value: "completed"
allow_multiple_values: true
ui_config:
type: dropdown_menu
display: popover
```
```tsx title="Holistics Filter Configuration"
Dashboard your_dashboard {
block status_filter: FilterBlock {
label: "Order Status"
type: "field"
source: FieldFilterSource {
dataset: orders
field: r(orders.status)
}
settings {
input_type: 'multiple'
}
default {
operator: "is"
value: 'completed'
}
}
}
```
Note:
* Holistics filter configuration is more streamlined and has cross-filtering ability by default.
* But currently doesn't support advanced UI configurations available in Looker.
### 3. Visualization Elements (Cartesian Chart)
```yaml title="Looker Visualization Elements (Cartesian Chart)"
- dashboard: sales_overview
tile_size: 100
elements:
- name: monthly_sales
title: "Monthly Sales"
type: looker_column # Looker Column Chart
height: 4 # sets an element to be 400 pixels in height (when tile_size is 100)
width: 5 # sets an element to be 500 pixels in width (when tile_size is 100)
top: 7 # position the element 700 pixels from the top of the dashboard
left: 8 # position the element 800 pixels from the left of the dashboard
## QUERY PARAMETERS
model: sales
explore: orders
fields: [orders.created_month, orders.total_revenue]
fill_fields: [orders.created_month]
sorts: [orders.created_month desc]
limit: 500
```
```tsx title="Holistics Visualization Elements (Cartesian Chart)"
Dashboard your_dashboard {
block block_name: FilterBlock { }
//highlight-start
block monthly_sales: VizBlock {
label: "Monthly Sales"
viz: ColumnChart {
dataset: orders
x_axis {
field: VizFieldFull {
ref: r(orders.created_month)
transformation: 'datetrunc month'
}
}
y_axis {
series {
field: VizFieldFull {
ref: r(orders.total_revenue)
}
}
}
settings {
row_limit: 500
sort {
field_index: 0
direction: 'desc'
}
}
}
}
//highlight-end
view: CanvasLayout {
block monthly_sales {
position: pos(800, 700, 500, 400) // position the element 800 pixels from the left of the dashboard, 700 pixels from the top, 500 pixels wide, and 400 pixels tall
}
}
}
```
Note: While both platforms support similar visualization capabilities, Holistics uses a more structured approach to defining chart components`.
### 4. Visualization Elements (Data Table)
```yaml title="Looker Visualization Elements (Data Table)"
- dashboard: sales_overview
tile_size: 100
elements:
- name: monthly_sales
title: "Monthly Sales"
type: looker_grid # Looker Data Table
height: 4 # sets an element to be 400 pixels in height (when tile_size is 100)
width: 5 # sets an element to be 500 pixels in width (when tile_size is 100)
top: 7 # position the element 700 pixels from the top of the dashboard
left: 8 # position the element 800 pixels from the left of the dashboard
## QUERY PARAMETERS
model: ecommerce
explore: orders
fields: [products.name, orders.total_orders_count, orders.gmv, orders.aov]
```
```tsx title="Holistics Visualization Elements (Data Table)"
Dashboard your_dashboard {
block block_name: FilterBlock { }
//highlight-start
block product_performance: VizBlock {
label: 'Product Performance'
viz: DataTable {
dataset: demo_ecommerce
fields: [
VizFieldFull {
ref: r(products.name)
format { } // Viz Field Format Configuration
},
VizFieldFull {
label: 'Total Orders'
ref: r(orders.total_orders_count)
format { } // Viz Field Format Configuration
},
VizFieldFull {
label: 'GMV'
ref: r(orders.gmv)
format { } // Viz Field Format Configuration
},
VizFieldFull {
label: 'AOV'
ref: r(orders.aov)
format { } // Viz Field Format Configuration
}
]
settings { } // Viz Settings Configuration
}
}
//highlight-end
view: CanvasLayout {
block product_performance {
position: pos(800, 700, 500, 400) // position the element 800 pixels from the left of the dashboard, 700 pixels from the top, 500 pixels wide, and 400 pixels tall
}
}
}
```
### 5. Visualization Elements (Pivot Table)
```yaml title="Looker Visualization Elements (Pivot Table)"
- dashboard: sales_overview
tile_size: 100
elements:
- name: order_by_country_and_status
title: "Total Orders by Countries & Order Status"
type: looker_grid # Looker Data Table
height: 4 # sets an element to be 400 pixels in height (when tile_size is 100)
width: 5 # sets an element to be 500 pixels in width (when tile_size is 100)
top: 7 # position the element 700 pixels from the top of the dashboard
left: 8 # position the element 800 pixels from the left of the dashboard
## QUERY PARAMETERS
model: ecommerce
explore: orders
fields: [countries.name, orders.status, orders.total_orders_count]
pivots: [orders.status]
```
```tsx title="Holistics Visualization Elements (Pivot Table)"
Dashboard your_dashboard {
block block_name: FilterBlock { }
//highlight-start
block order_by_country_and_status: VizBlock {
label: 'Total Orders by Countries & Order Status'
viz: PivotTable {
dataset: demo_ecommerce
rows: [
VizFieldFull {
ref: r(countries.name)
format { } // Viz Field Format Configuration
}
]
columns: [
VizFieldFull {
ref: r(orders.status)
format { } // Viz Field Format Configuration
}
]
values: [
VizFieldFull {
ref: r(orders.total_orders_count)
format { } // Viz Field Format Configuration
}
]
settings { } // Viz Settings Configuration
}
}
//highlight-end
view: CanvasLayout {
block order_by_country_and_status {
position: pos(800, 700, 500, 400) // position the element 800 pixels from the left of the dashboard, 700 pixels from the top, 500 pixels wide, and 400 pixels tall
}
}
}
```
Note: Looker doesn't support pivot type explicitly, but it can be achieved by using `looker_grid` type with `pivots` parameters.
### 6. Text and Markdown Content
```yml title="Looker Text Elements"
- dashboard: sales_overview
layout: tile
tile_size: 100
elements:
- name: dashboard_intro
type: text
height: 4 # sets an element to be 400 pixels in height (when tile_size is 100)
width: 5 # sets an element to be 500 pixels in width (when tile_size is 100)
top: 7 # position the element 700 pixels from the top of the dashboard
left: 8 # position the element 800 pixels from the left of the dashboard
## TEXT PARAMETERS
title_text: "Welcome"
subtitle_text: "Overview"
body_text: "
This dashboard shows:
• Daily revenue trends
• Order status breakdown
"
```
```tsx title="Holistics Text Elements"
Dashboard your_dashboard {
block block_name: FilterBlock { }
block block_name: VizBlock { }
//highlight-start
block dashboard_intro: TextBlock {
content: @md
# Welcome
## Overview
This dashboard shows:
* Daily revenue trends
* Order status breakdown
;;
}
//highlight-end
view: CanvasLayout {
block dashboard_intro {
position: pos(800, 700, 500, 400) // position the element 800 pixels from the left of the dashboard, 700 pixels from the top, 500 pixels wide, and 400 pixels tall
}
}
}
```
Note: Holistics uses Markdown syntax for text formatting, providing a more familiar editing experience for developers.
## Detailed Feature Comparison
| LookML Parameter | Purpose | Support | Holistics Equivalent & Implementation |
|-----------------|---------|----------------------|-----------------------------------|
| **Dashboard Parameters** | | | |
| dashboard | Create a dashboard. | ✅ | Using Holistics Dashboard syntax `Dashboard your_dashboard { }` |
| title (for dashboard) | Change the way a dashboard name appears to users. | ✅ | Using `title: 'Dashboard Title'` |
| description (for dashboard) | Add a description to a dashboard. | ✅ | Using `description: 'Dashboard description'` |
| enable_viz_full_screen | Define whether dashboard viewers can see dashboard tiles in full-screen and expanded views. | ❌ | Users can see full-screen VizBlock by default and admins/analysts cannot control this |
| extends | Base the LookML dashboard on another LookML dashboard. | ✅ | Using Holistics [AML Extend](/reference/aml/extend) |
| extension | Require that the dashboard is extended by another dashboard. | ✔️ (partial) | No direct equivalent, but can create [AML Func](/reference/aml/func) for a Dashboard|
| layout | Start a section of LookML to define the elements that should go into each row of a layout: grid dashboard. | ❌ | Not supported yet, but default, our dashboard will be layout as canvas. |
| elements (for rows) | Define the elements that should go into a row of a `layout: grid` dashboard. | ❌ | Holistics doesn't support grid layout. |
| height (for rows) | Define the height of a row for a `layout: grid` dashboard. | ❌ | Holistics doesn't support grid layout. |
| tile_size | Define the size of a tile for a `layout: tile` dashboard. | ❌ | Not supported yet. |
| width (for dashboard) | Define the width of the dashboard for a `layout: static` dashboard. | ✅| Can change both width and height of a Dashboard via view: `CanvasLayout { width: 1560 height: 1080 }` |
| refresh (for dashboard) | Set the interval on which dashboard elements will automatically refresh. Note that the dashboard must be open in a browser window for this parameter to have an effect. This setting does not run in the background to "pre-warm" the dashboard cache. | ❌ | Not suppported yet. |
| auto_run | Determine whether dashboards run automatically when initially opened or reloaded. | ✅ | By default, the dashboard runs automatically. You can disable this by using `settings { autorun_on_open: false }` |
| **Filter Parameters** | | | |
| crossfilter_enabled | Enable or disable cross-filtering for a dashboard. | ✅ | Holistics support [cross-filtering](/docs/cross-filtering) by default |
| filters_bar_collapsed | Set the dashboard filter bar as default collapsed or expanded for a dashboard. | ❌ | Holistics doesn't have dedicated filter bar as filters can place anywhere in the dashboard |
| filters_location_top | Set the dashboard filter bar location as top or right for a dashboard. | ✅ | Users can position filters anywhere in the dashboard |
| filters (for dashboard) | Start a section of LookML to define dashboard filters. | ✅ | Using `block block_name: FilterBlock { }` |
| name (for filters) | Create a filter. | ✅ | Using `block block_name: FilterBlock { }` |
| title (for filters) | Change the way a filter name appears to users. | ✅ | Using `label: 'Filter Label'` |
| type (for filters) | Determine the type of filter to be used. | ✅ | Using `type: 'field'` |
| default_value | Set a default value for a filter, if desired. | ✅ | Using `default { operator: 'is', value: ['value1', 'value2'] }` |
| allow_multiple_values | Limit users to a single filter value. | ✅ | Using `settings { input_type: 'single' }` |
| required | Require that a filter is selected. | ❌ | Not supported yet. |
| ui_config | Configure the filter controls that are available when users view a LookML dashboard. Has subparameters type, display, and options. | ❌ | Not supported yet. |
| model (for filters) | Specify the model that contains the underlying field of a `type: field_filter` filter. | ✅ | No equivalent in Holistics |
| explore (for filters) | Specify the Explore that contains the underlying field of a `type: field_filter` filter. | ✅ | Using `dataset: dataset_name` |
| field | Specify the underlying field of a `type: field_filter` filter. | ✅ | Using `field: r(model_name.field_name)` |
| listens_to_filters | Narrow suggestions for dashboard filters of field_filter based on what the user enters for another dashboard filters of `type: field_filter`. | ✅ | Holistics [Parrent-child filter](/docs/filters/parent-child) |
| **Common Chart Parameters** | | | |
| name (for elements) | Creates a new dashboard element and assigns it a name. | ✅ | Using `block block_name: VizBlock { }` |
| title (for elements) | Changes how an element's name will appear to users. | ✅ | Using `label: 'Element Label'` |
| type (for elements) | Determines the type of visualization to be used in the element. | ✅ | Using `viz: ChartType { }` |
| height (for elements) | Defines the height of an element in units of tile_size. | ✅ | specify height in `position: pos(left, top, width, height)` inside `view: CanvasLayout { }` |
| width (for elements) | Defines the width of an element in units of tile_size. | ✅ | specify width in `position: pos(left, top, width, height)` inside `view: CanvasLayout { }` |
| top | Defines the top-to-bottom position of an element in units of tile_size. | ✅ | specify top in `position: pos(left, top, width, height)` inside `view: CanvasLayout { }` |
| left | Defines the left-to-right position of an element in units of tile_size. | ✅ | specify left in `position: pos(left, top, width, height)` inside `view: CanvasLayout { }` |
| row | Defines the top-to-bottom position of an element in units of rows for `layout: newspaper` dashboards | ❌ | Holistics doesn't support newspaper layout yet |
| col | Defines the left-to-right position of an element in units of columns for `layout: newspaper` dashboards | ❌ | Holist ics doesn't support newspaper layout yet |
| refresh (for elements) | Sets the interval at which the element will automatically refresh. | ❌ | Not supported yet |
| note | Starts a section of LookML to define a note for an element. | ✅ | Using `description: 'your block description or note'` |
| **Query Parameters** | | | |
| model | Defines the model to be used for the element's query. | ✅ | Using `dataset: dataset_name` |
| explore (for elements) | Defines the Explore to be used for the element's query. | ✅ | Using `dataset: dataset_name` |
| dimensions | Defines the dimensions to be used for the element's query. | ✅ | Use - `x_axis` with LineChart, BarChart of ColumnChart - `fields: []` with DataTable - `rows: []` with Pivot Table |
| measures | Defines the measures to be used for the element's query. | ✅ | Use - `y_axis` with LineChart, BarChart of ColumnChart - `fields: []` with DataTable - `values: []` with Pivot Table |
| sorts | Defines the sorts to be used for the element's query. | ✅ | Using `sort { field_index: 0, direction: 'desc' }` |
| pivots | Defines the dimensions that should be pivoted for the element's query. | ✅ | Using - `legend:` with Cartesian Charts - `columns: []` with Pivot Table |
| limit | Defines the row limit to be used for the element's query. | ✅ | Using `settings { row_limit: 10000 }` |
| column_limit | Defines the column limit to be used for the element's query. | ❌ | Not supported yet |
| filters (for elements) | Defines the filters that cannot be changed for the element's query. | ✅ | Use filter { } |
| listen | Defines the filters that can be changed for the element's query. | ✅ | Support via `interactions: [ FilterInteraction {} ]` |
| query_timezone | Defines the time zone that should be used when the query is run. | ❌ | Follow Dashboard Timezone |
| merged_queries | Defines a merged results query. | ❌ | Not supported yet |
| hidden_fields | Specifies any fields to use in the query but hide in the chart. | ✅ | Use `hidden: true` inside `VizFieldFull {}` |
| **Plot Parameters for Cartesian Charts** | | | |
| stacking | Specifies how series are stacked in the visualization. | ✅ | Using - `settings { stack_series_by: 'value' }` - `settings { stack_series_by: 'percentage' }` |
| show_dropoff | Shows a dropoff marker for column charts. | ❌ | Not supported yet |
| ordering | Specifies how bars or columns are ordered within groups. | ✅ | Use `sort {}` inside setting of a VizBlock |
| column_spacing_ratio | Sets the spacing between columns. | ❌ | Not supported yet |
| column_group_spacing_ratio | Sets the spacing between column groups. | ❌ | Not supported yet |
| hide_legend | Hides the chart legend. | | |
| legend_position | Specifies the position of the legend. | | |
| limit_displayed_rows | Shows or hides rows in a visualization based on position. | | |
| swap_axes | Swaps the x and y axes. | | |
| interpolation | Specifies how points are connected in line and area charts. | | |
| show_null_points | Shows null values in the visualization. | | |
| discontinuous_nulls | Breaks the line or area at null values. | | |
| cluster_points | Groups points that are close together in scatter charts. | | |
| **Plot Parameters for Pie and Donut Charts** | | | |
| value_labels | Changes how individual sections of a pie chart are labeled. | | |
| label_type | Customizes the format of labels that mark sections of a pie chart. | | |
| inner_radius | Determines the inner radius of a pie chart (donut chart). | | |
| charts_across | Specifies the number of donut charts per row. | | |
| **Plot Parameters for Progression Charts** | | | |
| smoothedBars | Connects the outer edge of each bar in funnel charts. | | |
| isStepped | Displays the funnel chart in stepped funnel style. | | |
| orientation | Specifies whether data is drawn from rows or columns. | | |
| labelScale | Specifies the size of labels on chart bars and sides. | | |
| **Value Parameters** | | | |
| show_value_labels | Shows labels next to data points. | | |
| show_null_labels | Shows labels for null values. | | |
| label_color | Specifies the color for value labels. | | |
| font_size | Sets the font size of value labels. | | |
| label_rotation | Sets the rotation of value labels. | | |
| label_value_format | Specifies the number format for value labels. | | |
| show_totals_labels | Shows total values for stacked visualizations. | | |
| hidden_series | Hides specific series from the visualization. | | |
| show_silhouette | Shows a silhouette of the entire chart. | | |
| totals_color | Specifies the color for total labels. | | |
| **Series Parameters** | | | |
| colors | Sets the colors of chart series based on order. | | |
| series_colors | Sets the colors of chart series based on series name. | | |
| series_labels | Changes the way a series name appears to users. | | |
| series_types | Assigns different chart types to individual series. | | |
| show_view_names | Shows view names in chart labels. | | |
| point_style | Specifies the style of points in line and scatter charts. | | |
| series_point_styles | Sets point styles for specific series. | | |
| size_by_field | Specifies a field to determine point size in scatter charts. | | |
| plot_size_by_field | Enables sizing points by a field value. | | |
| **X-Axis Parameters** | | | |
| x_axis_scale | Specifies the scale type for the x-axis. | | |
| x_axis_reversed | Reverses the direction of the x-axis. | | |
| show_x_axis_label | Shows a label on the x-axis. | | |
| x_axis_label | Specifies a label for the x-axis. | | |
| show_x_axis_ticks | Shows value labels on the x-axis. | | |
| x_axis_gridlines | Shows gridlines from the x-axis. | | |
| x_axis_label_rotation | Sets the rotation of x-axis labels. | | |
| x_axis_datetime_label | Specifies a format for datetime x-axis labels. | | |
| x_axis_zoom | Enables zooming on the x-axis. | | |
| **Y-Axis Parameters** | | | |
| y_axis_gridlines | Shows gridlines from the y-axis. | | |
| y_axis_reversed | Reverses the direction of the y-axis. | | |
| reference_lines | Adds reference lines or regions to the chart. | | |
| y_axis_zoom | Enables zooming on the y-axis. | | |
| leftAxisLabelVisible | Shows a label on the left axis. | | |
| leftAxisLabel | Specifies a label for the left axis. | | |
| rightAxisLabelVisible | Shows a label on the right axis. | | |
| rightAxisLabel | Specifies a label for the right axis. | | |
| **Text Tile Parameters** | | | |
| title_text | Specifies a title for a text element. | | |
| subtitle_text | Specifies a subtitle for a text element. | | |
| body_text | Specifies body text for a text element. | | |
| **Single Value Parameters** | | | |
| custom_color_enabled | Enables custom color for single value visualization. | | |
| custom_color | Specifies a custom color for text in single value visualization. | | |
| show_single_value_title | Shows a title with the query's value. | | |
| single_value_title | Specifies a title to display with the query's value. | | |
| valueFormat | Specifies the number format for the value. | | |
| show_comparison | Adds comparison information to a single value element. | | |
| comparison_type | Specifies how the comparison field is used. | | |
| show_comparison_label | Shows a label with the comparison field. | | |
| comparison_label | Specifies the label for the comparison field. | | |
| comparison_reverse_colors | Reverses colors for negative/positive comparisons. | | |
| **Quadrant Parameters for Scatter Charts** | | | |
| quadrants_enabled | Enables quadrant divisions in scatter charts. | | |
| quadrant_properties | Configures properties for each quadrant. | | |
| custom_quadrant_point_x | Sets the x-coordinate for quadrant division. | | |
| custom_quadrant_point_y | Sets the y-coordinate for quadrant division. | | |
| custom_quadrant_value_x | Sets the x-value for quadrant division. | | |
| custom_quadrant_value_y | Sets the y-value for quadrant division. | | |
---
## Migrating Looker Dimensions to Holistics
## Overview
Looker dimensions and Holistics dimensions serve similar purposes but have some key differences in syntax and capabilities:

1. **Syntax Differences**
- Looker uses `${TABLE}` for table references, Holistics uses `#SOURCE`
- Looker uses `${field_name}` for field references, Holistics uses `{{ field_name }}`
- Looker uses `sql:` for definitions, Holistics uses `definition: @sql` or [`definition: @aql`](/as-code/aql/)
2. **Data Types**
- Looker: `string`, `number`, `time`, `yesno`
- Holistics: `text`, `number`, `datetime`/`date`, `truefalse`
3. **Date Handling**
- Looker uses `dimension_group` to create multiple time-based dimensions at modeling time
- Holistics provides flexible date handling at query time - users can change date grains and extract date parts directly in the UI without requiring predefined dimensions. Please visit [Date Drill](/docs/interactions/date-drills), [Date Part](/docs/datetimes/date-parts), and [Time Intelligence Functions](/reference/aql/time-intelligence-functions) for more details.
## Step-by-Step
### Step 1: Set Up Your Environment
1. Open your Looker view file containing the dimensions
2. Create a new Holistics [model file](/reference/aml/model)
3. Have the [type mapping reference](#type-mapping) ready
### Step 2: Migrate Dimensions
```tsx title="Handle simple column references"
// highlight-next-line
// Looker
dimension: first_name {
label: "First Name"
type: string
sql: ${TABLE}.first_name ;;
}
// highlight-next-line
// Holistics
dimension first_name {
label: 'First Name'
type: 'text'
definition: @sql {{ #SOURCE.first_name }};;
}
```
```tsx title="Handle Computed Dimensions"
// highlight-next-line
// Looker
dimension: full_name {
type: string
sql: CONCAT(${first_name}, ' ', ${last_name}) ;;
}
// highlight-next-line
// Holistics
dimension full_name {
type: 'text'
definition: @sql CONCAT({{ first_name }}, ' ', {{ last_name }});;
}
```
```tsx title="Handle Case Statements"
// highlight-next-line
// Looker
dimension: size_tier {
case: {
when: {
sql: ${size} < 10 ;;
label: "Small"
}
when: {
sql: ${size} >= 10 AND ${size} < 50 ;;
label: "Medium"
}
else: "Large"
}
}
// highlight-next-line
// Holistics (SQL definition)
dimension size_tier {
type: 'text'
definition: @sql
CASE
WHEN {{ size }} < 10 THEN 'Small'
WHEN {{ size }} >= 10 AND {{ size }} < 50 THEN 'Medium'
ELSE 'Large'
END;;
}
// highlight-next-line
// Holistics (AQL definition)
dimension size_tier {
type: 'text'
definition: @aql
case(
when: model.size < 10, then: 'Small',
when: model.size >= 10 AND model.size < 50, then: 'Medium',
else: 'Large'
)
;;
}
```
```tsx title="Handle Date Dimensions"
// highlight-next-line
// Looker
dimension_group: created {
type: time
timeframes: [date, week, month, year]
sql: ${TABLE}.created_at ;;
}
// highlight-next-line
// Holistics
dimension created_at {
type: 'datetime'
definition: @sql {{ #SOURCE.created_at }};;
}
```
In Holistics, users can change date grains (year, quarter, month, week, date)
and extract date parts directly in the UI without requiring predefined dimensions like in Looker. Please refer to:
* [Date Drill](/docs/interactions/date-drills)
* [Date Parts](/docs/datetimes/date-parts)
* [Time Intelligence Functions](/reference/aql/time-intelligence-functions)
### Step 3: Test and Validate
1. Check all dimension types are correctly mapped
2. Verify SQL references are properly converted
3. Test each dimension in a simple report
4. Compare results with original Looker dimensions
## Reference Manual
### Type Mapping {#type-mapping}
| Looker Type | Holistics Type | Notes |
|-------------|----------------|-------|
| `string` | `text` | Direct mapping |
| `number` | `number` | Direct mapping |
| `time` | `datetime` | Use `date` for date-only values |
| `yesno` | `truefalse` | Direct mapping |
| `tier` | `text` | Convert to CASE statement |
| `location` | `text` | No direct equivalent |
### Property Mapping
| Looker Property | Holistics Property | Notes |
|----------------|-------------------|-------|
| `label` | `label` | Direct mapping |
| `description` | `description` | Direct mapping |
| `hidden` | `hidden` | Use `true`/`false` instead of `yes`/`no` |
| `sql` | `definition` | Must include `@sql` or `@aql` tag |
| `type` | `type` | See [Type Mapping](#type-mapping) |
### Unsupported Features
The following Looker features don't have direct equivalents in Holistics:
- `alpha_sort`
- `order_by_field`
- `suggest_dimension`
- `suggest_explore`
- `tags`
- `drill_fields` -> will be supported in the future ([learn more](/docs/interactions/drill-down))
### Best Practices
1. **Use AQL for Complex Transformations**
- AQL provides better readability and maintainability
- Easier to handle complex business logic
- Better error messages and type checking
2. **Consistent Naming**
- Keep dimension names consistent with Looker for easier maintenance
- Use clear, descriptive names for new dimensions
3. **Documentation**
- Always include descriptions for dimensions
- Document any deviations from Looker implementation
4. **Testing**
- Create test reports to validate migrated dimensions
- Compare results with original Looker reports
- Test edge cases and null handling
## Detailed Feature Comparison
| LookML Parameter | Purpose | Support | Holistics Equivalent & Implementation |
|-----------------|---------|----------|-----------------------------------|
| **Structural Parameters** | | | |
| `dimension` | Creates a dimension field | ✅ | Holistics [dimension](/reference/aml/field) |
| `dimension_group` | Creates several time-based dimensions at the same time | ✅ | Holistics has several equivalent options: - [Date Drill](/docs/interactions/date-drills) - [Date Part](/docs/datetimes/date-parts) - [Time Intelligence Functions](/reference/aql/time-intelligence-functions) |
| **Action and Linking Parameters** | | | |
| `action` | Creates clickable actions on dimension values | ✔️ (partial) | Holistics [Actions](/docs/actions) Note: Holistics Actions can only be defined in Dashboard layer |
| `drill_fields` | Specifies fields to show when drilling into dimension | 🛠️ (will support soon) | Holistics [Drill Down](/docs/interactions/drill-down) |
| `tags` | Adds text that can be passed to other applications | ❌ | No direct equivalent |
| `link` | Creates links to other Looker content or external URLs | ✅ | Can be solved with Holistics [Actions](/docs/actions) |
| **Display Parameters** | | | |
| `alias` | Creates alternate names for backward compatibility | ❌ | No direct equivalent |
| `alpha_sort` | Makes case parameter sort conditions alphabetically | ❌ | No direct equivalent |
| `description` | Adds explanatory text for the dimension | ✅ | Uses `description` parameter |
| `group_label` | Groups dimensions together under a heading | ❌ | No direct equivalent |
| `group_item_label` | Specifies label for field under its group label | ❌ | No direct equivalent |
| `hidden` | Controls visibility in the UI | ✅ | Uses `hidden` parameter with true/false |
| `label` | Changes how the dimension appears in the UI | ✅ | Uses `label` parameter |
| `order_by_field` | Sorts dimension by values of another field | ❌ | No direct equivalent |
| `style` | Changes how tiers appear in UI | ❌ | No direct equivalent |
| `view_label` | Changes how dimension's view appears in field picker | ❌ | No direct equivalent |
| **Filter Parameters** | | | |
| `can_filter` | Lets you prohibit a dimension from being used as a filter | ❌ | No direct equivalent |
| `case_sensitive` | Controls case sensitivity in filters | ❌ | No direct equivalent |
| `skip_drill_filter` | Stops dimension from being added to filters when drilling | ❌ | No direct equivalent |
| **Filter Suggestion Parameters** | | | |
| `bypass_suggest_restrictions` | Shows suggestions when sql_always_where is in use | ❌ | No direct equivalent |
| `full_suggestions` | Controls how database is queried for suggestions | ❌ | No direct equivalent |
| `suggest_dimension` | Bases suggestions on values of different dimension | ❌ | No direct equivalent |
| `suggest_explore` | Bases suggestions on values of different explore | ❌ | No direct equivalent |
| `suggest_persist_for` | Changes cache settings for suggestions | ❌ | No direct equivalent |
| `suggestable` | Enables or disables suggestions for a field | ❌ | No direct equivalent |
| `suggestions` | Controls filter suggestions | ❌ | No direct equivalent |
| **Query Parameters** | | | |
| `convert_tz` | Controls timezone conversion | ❌ | No direct equivalent |
| `primary_key` | Indicates if the dimension is a primary key | ❌ | No direct equivalent |
| `required_access_grants` | Controls access based on user attributes | ❌ | No direct equivalent in basic AMQL, but Holistics handles access control differently |
| `required_fields` | Requires additional fields when this field is chosen | ❌ | No direct equivalent |
| **Value and Formatting Parameters** | | | |
| `html` | Modifies HTML output using Liquid templating | ❌ | No direct equivalent |
| `sql` | Defines the SQL expression for the dimension | ✅ | Uses `definition` with either `@sql` or `@aql` tag |
| `sql_latitude` | Defines latitude for location type dimensions | ❌ | No direct equivalent |
| `sql_longitude` | Defines longitude for location type dimensions | ❌ | No direct equivalent |
| `type` | Specifies the data type of the dimension | ✅ | Uses `type` with values: 'text', 'number', 'date', 'datetime', 'truefalse' |
| **Visualization Parameters** | | | |
| `map_layer_name` | Specifies custom map for geographic data | ❌ | No direct equivalent |
(*) Looker Parameters that are not mentioned in this table are generally not supported
---
## Migrating Looker Explores to Holistics
## High-level Overview
### Looker Explore vs Holistics Dataset
In principle, an explore in Looker corresponds to a dataset in Holistics.

### Dynamic Root Models vs. Root Models
Looker's explore feature is a SQL generation interface that creates a series of JOINs based on the fields you select. The first table in the FROM clause of the generated query represents the initial view in Looker's explore (or in Holistics, we call it root model).

This means that, even when you only select fields in `users` view (via Field Picker UI), Looker will always generate the SQL starting from the root model `events` to `users` via your specified joins.
In Holistics, the table in the from clause is dynamic based on which fields are selected in the our Explore UI
### Relationships over Joins
A key distinction between Looker and Holistics lies in how they handle joins. Holistics uses relationships to automatically generate appropriate joins, whereas Looker requires users to pre-define the joins in advance within their explores.

## Step-by-Step Migration Tutorial
### Using UI to create Dataset
After migrating Looker Views to Holistics Models, you can then create Dataset from these Models.
Follow the video guide below
### Using Code to create Dataset
#### Step 1: Set Up Your Environment
1. Open your Looker explore file
2. Create a new Holistics [dataset](/reference/aml/dataset)
3. Create dataset object inside dataset file `Dataset name { }`
4. Identify the connection name, all views, and their relationships
#### Step 2: Add Data Source Name to Your Dataset
Unlike Looker where connections are defined at the model level, Holistics requires explicitly specifying the data source for each dataset. Check the `connection` property in your Looker model file and add it to your Holistics dataset:
```tsx title="Looker model file"
connection: "warehouse" // Defined once at model level
include: "views/*.view"
explore: ecommerce {
view_name: order_items
join: products { ... }
join: orders { ... }
}
```
```tsx title="Holistics dataset file"
Dataset ecommerce {
data_source_name: 'warehouse' // Required in each dataset
}
```
For more details on connection differences, see [Conceptual Differences: Data Source Connection](/docs/from-others/looker/conceptual-differences#data-source-connection).
#### Step 3: Define Models
List all models used in the explore:
```tsx
// Looker
explore: ecommerce {
view_name: order_items
join: products { ... }
join: orders { ... }
}
// Holistics
Dataset ecommerce {
models: [
order_items,
products,
orders
]
}
```
#### Step 4: Convert Joins to Relationships
From the Looker explore below, we can identify:
- 5 views that will become models in Holistics: `order_items`, `products`, `orders`, `merchants`, and `users`
- The relationships between these models:
- `order_items` many-to-one `products` (via product_id)
- `order_items` many-to-one `orders` (via order_id)
- `products` many-to-one `merchants` (via merchant_id)
- `orders` many-to-one `users` (via user_id)
```tsx
// Looker
explore: order_items {
join: products {
type: left_outer
sql_on: ${order_items.product_id} = ${products.id} ;;
relationship: many_to_one
}
join: orders {
type: left_outer
sql_on: ${order_items.order_id} = ${orders.id} ;;
relationship: many_to_one
}
join: merchants {
type: left_outer
sql_on: ${products.merchant_id} = ${merchants.id} ;;
relationship: many_to_one
}
join: users {
type: left_outer
sql_on: ${orders.user_id} = ${users.id} ;;
relationship: many_to_one
}
}
```
In Holistics, we express these relationships using the `relationship` syntax. The arrow direction (`>`) indicates the many-to-one relationship:
```tsx
// Holistics
Dataset ecommerce {
models: [
order_items,
products,
orders,
merchants,
users
]
relationships: [
// orders -> users (n-1)
relationship(orders.user_id > users.id, true),
// order_items -> orders (n-1)
relationship(order_items.order_id > orders.id, true),
// order_items -> products (n-1)
relationship(order_items.product_id > products.id, true),
// products -> merchants (n-1)
relationship(products.merchant_id > merchants.id, true),
]
}
```
#### Step 5: Handle Access Control
**In Looker**
```tsx
// Looker
access_filter: {
field: orders.merchant_id
user_attribute: merchant_id
}
```
**In Holistics**
Use Holistics [Row-level Permission](/docs/access-control/row-level-permission) instead
#### Step 6: Test and Validate
1. Test common field combinations
2. Verify join paths are correct
3. Compare query results with Looker
4. Test access controls
## Reference Manual
### Property Mapping
| Looker Property | Holistics Property | Notes |
|----------------|-------------------|-------|
| `view_name` | N/A | Holistics uses dynamic root model |
| `join` | `relationships` | Define relationships between models |
| `sql_on` | `relationship()` | Use relationship syntax |
| `fields` | Dataset View | Use [Dataset Custom View](/docs/datasets/custom-views) |
| `access_filter` | Row-level Permission | Use [Row-level Permission](/docs/access-control/row-level-permission) |
### Common Patterns
1. **Dataset Organization**
```tsx
// Looker - using view_name and joins
explore: ecommerce {
view_name: order_items
join: products { ... }
join: orders { ... }
}
// Holistics - list all models and their relationships
Dataset ecommerce {
models: [
order_items,
products,
orders
]
relationships: [
relationship(order_items.product_id > products.id, true),
relationship(order_items.order_id > orders.id, true)
]
}
```
2. **Field Selection**
```tsx
// Looker - using fields parameter
explore: ecommerce {
fields: [
orders.id,
orders.status,
users.email
]
}
// Holistics - using Dataset View
Dataset ecommerce {
// ... other configurations
view main {
fields: [
orders.id,
orders.status,
users.email
]
}
}
```
## Detailed Feature Comparison
| LookML Parameter | Purpose | Support | Holistics Equivalent & Implementation |
|-----------------|---------|----------------------|-----------------------------------|
| **Structural Parameters** | | | |
| `extends` | The extends parameter lets you build upon the content and settings from another Explore | ✅ | Holistics [AML Extend](/reference/aml/extend) |
| `extension: required` | flags an Explore as requiring extension | ✔️ (partial) | Holistics doesn't support this exact concept but we can rebuild this via [Function](/reference/aml/func) |
| `fields: [field-ref]` | specify which fields from an Explore are exposed in the Explore UI | ✅ | Holistics [Dataset Custom View](/docs/datasets/custom-views) |
| `tags: ["string"]` | Specifies text that can be passed to other applications | 🕑 Coming Soon | |
| **Display Parameters** | | | |
| `description` | add information about the Explore to the UI | ✅ | Dataset description: `description: "your_description"` |
| `group_label` | change the default organization of the Explore menu | ✔️ (partially) | Holistics doesn't have the exact concept, but users can manually organize the Dataset to appropriate folders in Reporting layer |
| `hidden: yes or no` | Hides an Explore from the Explore menu | ❌ | |
| `label` | Changes the way an Explore appears in the Explore menu | ✅ | Holistics Dataset Label: `label: "Your Dataset"` |
| `query` | Creates a predefined query for users to select in an Explore's Quick Start menu | ❌ | |
| `view_label` | Specifies how a group of fields from the Explore's base view will be labeled in the field picker | ❌ | |
| **Filter Parameters** | | | |
| `access_filter` | Adds user-specific filters to an Explore | ✅ | Holistics [Row-level Permission](/docs/access-control/row-level-permission) |
| `always_filter` | Adds filters a user can change, but not remove, to an Explore | ❌ | |
| `case_sensitive` | Specifies whether filters are case-sensitive for an Explore | ❌ | |
| `sql_always_where: sql-block ;;` | Inserts conditions into the query's **WHERE** clause that a user cannot change or remove | ❌ | |
| `sql_always_having: sql-block ;;` | Inserts conditions into the query's **HAVING** clause that a user cannot change or remove | ❌ | |
| **Join Parameters** | | | |
| `always_join: [field-ref]` | forces one or more joins to be included in the SQL that Looker generates | ❌ | |
| `join: identifier` | define the join relationship between an Explore and a view | ❌ | Holistics uses [relationship](/docs/relationships) instead of pre-defined joins |
| **Query Parameters** | | | |
| `cancel_grouping_fields: [field-ref]` | stop Looker from adding a `GROUP BY` clause to the SQL | ❌ | |
| `from: view-ref` | Determines the view that will define the fields of an Explore | ❌ | Holistics doesn't have root model concept |
| `persist_for` | modify the amount of time that cached query results are used | ❌ | |
| `persist_with` | specify a `datagroup` caching policy to use for that specific Explore | ❌ | |
| `required_access_grants: [access-grant-ref]` | Limits access to the Explore to only users whose user attribute values match the access grants | ✔️ (partially) | Share Dataset with Explorers and Viewers to explore. Check our ["Dataset Sharing" document](/docs/datasets#share-dataset) |
| `sql_table_name: sql-block ;;` | Specifies the database table on which an Explore will be based | ❌ | |
| `view_name: view-ref` | Specifies the view on which an Explore will be based | ❌ | Holistics doesn't have root model concept |
| **Aggregate Table Parameters** | | | |
| `aggregate_table` | create aggregate tables that will minimize the number of queries required | ✅ | Aggregated Awareness ([Pre-aggregate](/docs/aggregate-awareness/quick-start#define-the-pre-aggregate)) |
| **Refinement Parameters** | | | |
| `final: yes or no` | Indicates that the current refinement is the final refinement allowed for the Explore | ❌ | |
(*) Looker Parameters that are not mentioned in this table are generally not supported
---
## From Looker to Holistics
:::caution Looker Core vs Looker Studio
Whenever Looker is mentioned in this document, we are talking about **[Looker Core](https://cloud.google.com/looker/docs/looker-core-overview)**, which is a BI product [acquired by Google in 2020](https://techcrunch.com/2020/02/13/google-closes-2-6b-looker-acquisition/), and **not about** [Looker Studio](https://lookerstudio.google.com/u/0/) (previously known as Google Data Studio).
:::
:::info
This document was written in June 2024, and may have become inaccurate as Holistics and Looker continue to improve their products.
:::
## Introduction
This document aims to give a high level comparison between Holistics and Looker, and to smoothen to the transition for existing Looker customers to Holistics by showing how you can achieve common Looker use cases using Holistics features.
## High level similarities
Both Holistics and Looker are built on the insight that analytics development has **two main types** of stakeholders: **analysts** who develop the analytics foundation for an organization, and **business users** who want to consume and **self-serve** their analytics needs. Both understand the similarities between analytics development and software development, and thus **borrow practices from the latter** in their product design.
As a result, both Looker and Holistics enable **self-service analytics** through a **code-based semantic (or modeling) layer**. Both Holistics and Looker:
- Use a **declarative language** (Holistics' [AMQL](/as-code/amql/) vs Looker's [LookML](https://cloud.google.com/looker/docs/what-is-lookml)) to describe both the semantic layer and the visualization layer
- Provide a **self-served interface** for business users to explore data
- Analysts can **use SQL to define dimension and measure** (in Holistics, you can additionally use [AQL](/as-code/aql/learn-in-30-minutes))
- **SQL queries are dynamically generated** from semantic layer logic during the self-service process and get sent to customer data warehouses
Consequently, both Holistics and Looker provides good support for:
- Data governance through Git integration, CI/CD and approval/auditing process
- Strong analytics reusability and maintainability
## High level differences
### Modeling and Semantic Layer
To enable self-service data exploration, Looker has the concept of LookML models, explores and views. The analogous concepts in Holistics are dataset and models. The key difference between the two lies in the way exploration is enabled. In Looker, a LookML's explore is a view (represents a table) that users can query. It uses **[explicit join type](https://cloud.google.com/looker/docs/reference/param-explore-join-type)** from **a specific LookML view**. On the other hand, Holistics's dataset uses **[relationship type](/docs/datasets/dataset-relationships)** **without a specific starting table/model** (and compiles to appropriate join type at runtime). As a result, Holistics is more flexible as the join can be dynamically chosen to fit the use case. On the other hand, Looker gives more explicit control over the join type of the generated SQL but this control is only static ([which has its own downside](https://www.reddit.com/r/Looker/comments/1coqeix/alternative_to_suggest_explore)). The consequence is that Holistics and Looker handle [fan-out problem](https://www.googlecloudcommunity.com/gc/Technical-Tips-Tricks/The-problem-of-SQL-fanouts/ta-p/587483) differently. Holistics chooses the right join type to avoid the problem while Looker uses [symmetric aggregates](https://cloud.google.com/looker/docs/best-practices/understanding-symmetric-aggregates).
On the **metric building** aspect, Looker only supports SQL to define metrics while Holistics supports **both SQL and AQL** to define metrics. Holistics **AQL metrics are more robust than SQL ones and can support more use cases natively instead of depending on dimensional modeling (building derived tables) with SQL**. For example, [calculate cumulative sum in Looker requires a derived table](https://www.googlecloudcommunity.com/gc/Modeling/How-to-calculate-cumulative-totals-on-a-measure-in-a-pivot-table/m-p/569910/highlight/true#M1036) while it is a one-line of code in AQL using `running_total` or `window_sum`/`window_avg` function. Another example is [nested aggregation in Looker](https://www.googlecloudcommunity.com/gc/Technical-Tips-Tricks/ERROR-Measures-with-Looker-aggregations-sum-average-min-max-list/ta-p/592389) vs [Holistics](/as-code/aql/cookbook/aql-nested-aggregation). The downside of Holistics is that there is a learning curve associated with using AQL as it is not based on SQL.
### Visualization
Looker has two types of dashboards: [user-defined dashboards](https://cloud.google.com/looker/docs/creating-user-defined-dashboards) and [LookML dashboards](https://cloud.google.com/looker/docs/reference/lookml-dashboard-overview). The key difference is that user-defined dashboards are not "as-code" while LookML dashboards are.
Holistics also has two types of dashboards: the legacy 3.0 dashboards and the new Canvas Dashboards. Compared to Looker's dashboards, Holistics' Canvas Dashboards provide both a **code and visual** interface for analysts to build dashboards.
Due to the nature of its canvas-based structure, Holistics' Canvas Dashboards are **more flexible** than Looker, and can provide features such as:
- Freely put a tile/widget anywhere on a dashboard, even on top of each other for a specific effect
- Put interactive controls near your charts for better user experience
- [Custom tooltip](/docs/charts/customizing-chart-tooltip) that displays additional information when user hovers on chart data points
In terms of **custom visualization**, both Holistics and Looker supports ways to build visualizations that are not supported natively by the platforms. In Looker, developing custom visualization requires [knowledge of Javascript](https://github.com/looker-open-source/custom_visualizations_v2/blob/master/docs/getting_started.md). Holistics, on the other hand, leverages [Vega-lite](https://vega.github.io/vega-lite/), which provides a declarative way to define [custom charts](/docs/charts/custom-charts). Both has their own advantages and disadvantages. Looker's approach is more flexible, but also takes a lot more effort compared to Holistics' approach. In addition, Holistics' custom charts integrate better with the platform as the inputs from end-user perspectice [are not different](/docs/charts/understand-custom-chart#field-definition) from built-in visualizations.
### Self-service analytics
Both Holistics and Looker provide ability for business user to explore and build their own visualization through a drag-and-drop interface. Holistics, in addition, has built-in point-and-click GUI for common analytics needs such as [period comparison](/docs/period-comparison).
Holistics previously had a feature similar to [Looker table calculation](https://cloud.google.com/looker/docs/table-calculations), but is now superseded by AQL, which acts as a more powerful/robust replacement as it can work on both the underlying SQL as well as the visible table results.
### Embedded analytics
Both Holistics and Looker provide secure embedded analytics insider customers' own applications using iframes. Holistics' approach [uses JWT](/embedded/security#mechanism), and is similar to Looker's [signed embedding](https://cloud.google.com/looker/docs/signed-embedding).
Currently Holistics hasn't yet supported programmable interaction between the host application and the embedded iframes while Looker has support for this feature through [JavaScript event](https://cloud.google.com/looker/docs/embedded-javascript-events) or [Looker Embed SDK](https://cloud.google.com/looker/docs/embed-sdk-intro).
---
## Migrating Looker Measures to Holistics
## Overview
Looker measures and Holistics measures serve similar purposes but have some key differences in syntax and capabilities:

1. **Definition Approaches**
- Looker uses only SQL definitions
- Holistics supports both SQL and [AQL](/as-code/aql/) definitions
2. **Measure Types**
- **Looker** has [three measure type categories](https://cloud.google.com/looker/docs/reference/param-measure-types#measure_type_categories):
- *Aggregate*: `average`, `count`, `count_distinct`, `list`, `percentile`, `percentile_distinct`, `sum`
- *Non-aggregate*: `string`, `number`, `date`, `yesno`
- *Post-SQL*: `percent_of_total`, `running_total`, `percent_of_previous`
- **Holistics** uses a unified measure type with flexible definitions (`text`, `number`, `date`, `datetime`, `truefalse`, `json`)
## Step-by-Step Migration
### Step 1: Set Up Your Environment
1. Open your Looker view file containing the measures
2. Create a new Holistics [model file](/reference/aml/model)
3. Have the [type mapping reference](#type-mapping) ready
### Step 2: Migrate Measures
```tsx title="Basic Aggregations"
// highlight-next-line
// Looker
view: view_name {
dimension: sale_amount {}
measure: total_sales {
type: sum
sql: ${sale_amount} ;;
}
}
// highlight-next-line
// Holistics - SQL Definition
Model model_name {
dimension sale_amount {}
measure total_sales {
type: 'number'
definition: @sql sum({{ sale_amount }});;
}
}
// highlight-next-line
// Holistics - AQL Definition
Model model_name {
dimension sale_amount {}
measure total_sales {
type: 'number'
definition: @aql sum(model_name.sale_amount);;
}
}
```
```tsx title="Distinct Counts"
// highlight-next-line
// Looker
view: view_name {
dimension: customer_id {}
measure: unique_customers {
type: count_distinct
sql: ${customer_id} ;;
}
}
// highlight-next-line
// Holistics
Model model_name {
dimension customer_id {}
measure unique_customers {
type: 'number'
definition: @aql count_distinct(model_name.customer_id);;
}
}
```
```tsx title="Conditional Aggregations"
// highlight-next-line
// Looker
view: view_name {
dimension: state {}
measure: customers_by_state {
type: count
filters: [state: "California, Nevada, Washington, Oregon"]
}
}
// highlight-next-line
// Holistics
Model model_name {
dimension state {}
measure customers_by_state {
type: 'number'
definition: @aql
count(model_name.customer_id)
| where(model_name.state in ["California", "Nevada", "Washington", "Oregon"]);;
}
}
```
```tsx title="Running Totals"
// highlight-next-line
// Looker (post-query calculation)
view: view_name {
measure: sale_amount {}
measure: running_total {
type: running_total
sql: ${sale_amount} ;;
}
}
// highlight-next-line
// Holistics (in-query calculation)
Model model_name {
measure sale_amount {}
measure running_total {
type: 'number'
definition: @aql running_total(model_name.sale_amount) ;;
}
}
```
```tsx title="Percent of Previous"
// highlight-next-line
// Looker (post-query calculation)
view: view_name {
measure: mrr {}
measure: count_growth {
type: percent_of_previous
sql: ${mrr} ;;
}
}
// highlight-next-line
// Holistics (in-query calculation)
Model model_name {
dimension date {}
measure mrr {}
measure count_growth {
type: 'number'
definition: @aql
( model_name.mrr - previous(model_name.mrr, order: model_name.date) )
/
previous(model_name.mrr, order: model_name.date)
;;
}
}
```
```tsx title="Percent of Total"
// highlight-next-line
// Looker (post-query calculation)
view: view_name {
measure: total_gross_margin {}
measure: percent_of_total_gross_margin {
type: percent_of_total
sql: ${total_gross_margin} ;;
}
}
// highlight-next-line
// Holistics (in-query calculation)
Model model_name {
measure total_gross_margin {}
measure percent_of_total_gross_margin {
type: 'number'
definition: @aql
(model_name.total_gross_margin) * 1.0
/
(model_name.total_gross_margin | of_all(model_name))
;;
}
}
```
### Step 3: Test and Validate
1. Check all measure types are correctly mapped
2. Verify aggregations produce expected results
3. Test measures in simple reports
4. Compare results with original Looker measures
## Reference Manual
### Type Mapping {#type-mapping}
| Looker Type | Holistics Definition | Notes |
|-------------|---------------------|-------|
| `sum` | `sum()` | Direct mapping |
| `average` | `average()` | Direct mapping |
| `count` | `count()` | Direct mapping |
| `count_distinct` | `count(distinct: true)` | Use AQL syntax |
| `list` | `unique()` | Returns array of values |
| `percent_of_total` | AQL expression | Use [percent_of_total](/as-code/aql/cookbook/aql-percent-of-total) |
| `running_total` | AQL expression | Use [running_total](/reference/aql/running_total) |
## Detailed Feature Comparison
| LookML Parameter | Purpose | Support | Holistics Equivalent & Implementation |
|-----------------|---------|----------|-----------------------------------|
| **Structural Parameters** | | | |
| `measure` | Creates a measure field | ✅ | Holistics [measure](/reference/aql/type-measure) and [metric](/reference/aml/dataset-field#metric) |
| **Action and Linking Parameters** | | | |
| `action` | Creates clickable actions on measure values | ✔️ (partial) | Holistics [Actions](/docs/actions) Note: Holistics Actions can only be defined in Dashboard layer |
| `drill_fields` | Specifies fields to show when drilling into measure | 🛠️ (will support soon) | Holistics [Drill Down](/docs/interactions/drill-down) |
| `tags` | Adds text that can be passed to other applications | ❌ | No direct equivalent |
| `link` | Creates links to other Looker content or external URLs | ✅ | Can be solved with Holistics [Actions](/docs/actions) |
| **Display Parameters** | | | |
| `alias` | Creates alternate names for backward compatibility | ❌ | No direct equivalent |
| `description` | Adds explanatory text for the measure | ✅ | Uses `description` parameter |
| `group_label` | Groups measures together under a heading | ❌ | No direct equivalent |
| `group_item_label` | Specifies label for field under its group label | ❌ | No direct equivalent |
| `hidden` | Controls visibility in the UI | ✅ | Uses `hidden` parameter with true/false |
| `label` | Changes how the measure appears in the UI | ✅ | Uses `label` parameter |
| `order_by_field` | Sorts measure by values of another field | ❌ | No direct equivalent |
| `view_label` | Changes how measure's view appears in field picker | ❌ | No direct equivalent |
| **Filter Parameters** | | | |
| `can_filter` | Lets you prohibit a measure from being used as a filter | ❌ | No direct equivalent |
| `filters` (for measure) | Restricts a measure's calculation based on dimension limitations. Only with the following measure types that perform aggregation: - `count` - `sum` - `average` - `count_distinct` | ✅ | Using AQL [**`where` function**](/reference/aql/where). Unlike Looker, this function can work with all type of [aggregate functions](/reference/aql/aggregator-functions) |
| **Filter Suggestion Parameters** | | | |
| `suggest_dimension` | Bases suggestions on values of different dimension | ❌ | No direct equivalent |
| `suggest_explore` | Bases suggestions on values of different explore | ❌ | No direct equivalent |
| `suggestable` | Enables or disables suggestions for a field | ❌ | No direct equivalent |
| **Query Parameters** | | | |
| `convert_tz` | Controls timezone conversion | ❌ | No direct equivalent |
| `datatype` | Specifies the type of time data you are providing to a field | | |
| `required_access_grants` | Controls access based on user attributes | ❌ | No direct equivalent in basic AMQL, but Holistics handles access control differently |
| `required_fields` | Requires additional fields when this field is chosen | ❌ | No direct equivalent |
| `sql_distinct_key` | Defines the unique entities over which a measure of `type: sum_distinct` or `type: average_distinct` will be calculated | ✅ | Check how Holistics handles [Fanout issue](/docs/joins/troubleshooting-fanout) |
| **Value and Formatting Parameters** | | | |
| `direction` | Determines the direction that a measure of `type: percent_of_total` or `type: running_total` is calculated when pivots are used | ✅ | Holistics supports native [Percent of Total calculation](/docs/percent-of-total) |
| `html` | Modifies HTML output using Liquid templating | ✅ | Holistics supports [HTML Format](/docs/data-format#1-text) |
| `list_field` | Declares the dimension from which a measure of `type: list` will be calculated | ✅ | Using AQL [unique](/reference/aql/unique) function |
| `percentile` | Specifies the fractional value (the Nth percentile) for a measure of `type: percentile` or `type: percentile_distinct` | ❌ | Not supported yet. |
| `type` (for measure) | Specifies the type of measure Measure type categories: - **Aggregate measures** (average, count, sum, etc.) - **Non-aggregate measures** (date, number, string, etc.) - **Post-SQL measures** (Percent of Total, Running Total, etc.) | ✅ | In Holistics, type for: - **Aggregate Meaasure** is supported via [SQL definition](/reference/aql/type-measure#sql-definition-of-measure) or [AQL definition](/reference/aql/expression) - **Non-aggregate measures** are supported via `type` in measure/metric - **Post-SQL Measures** are supported via AQL Expression (e.g., [Percent of Total](/as-code/aql/cookbook/aql-percent-of-total)), but we will perform the calculation in the query, instead of after generating the query like Looker |
| `value_format` | Formats the output of a field using Excel style options | ✅ | Holistics supports [Data Format](/reference/aml/data-format) |
| `value_format_name` | Formats the output of a field using a built-in or custom format | ❌ | Not supported custom format yet. |
---
## Migrating from Looker to Holistics: Quick Start
If you're familiar with Looker, this guide will help you understand Holistics through concepts you already know. We'll cover the key differences, similarities, and provide a step-by-step migration path.
## Overview of the migration process
1/ Preparation: Familarize yourself with:
- [Conceptual differences](/docs/from-others/looker/conceptual-differences) between Looker and Holistics
- Holistics concepts: [Models](/docs/data-model), [Datasets](/docs/datasets), [Dashboards](/docs/dashboards/)
2/ Migrating the project one concept at a time:
- [Migrate **Looker views**](/docs/from-others/looker/views-migration) to Holistics models
- Inside the views, migrate [**dimensions**](/docs/from-others/looker/dimension-migration) and [**measures**](/docs/from-others/looker/measure-migration)
- Migrate [**Looker Model**](/docs/from-others/looker/model-migration) and [**Looker explores**](/docs/from-others/looker/explore-migration) to Holistics datasets
- [Migrate **Looker dashboards**](/docs/from-others/looker/dashboard-migration) to Holistics dashboards
## Migration Tool
We're developing an automated converter to help you quickly migrate your Looker projects to Holistics.
Check it out at [Migration Tool](/docs/from-others/looker/migration-tool)
## Project Structure
If you're used to organizing your LookML files, here's how Holistics structures projects:
```
📁 project/
📁 models/ # Like your Looker views
📁 tables/ # For regular table views
📄 orders.model.aml
📄 users.model.aml
📁 queries/ # For derived table views
📄 active_users.model.aml
📁 datasets/ # Like your Looker explores
📄 ecommerce.dataset.aml
📄 marketing.dataset.aml
📁 dashboards/ # Your dashboard definitions
📄 sales.page.aml
📄 performance.page.aml
```
## Migration Steps
### 1. Prepare Your Environment
1. Set up your Holistics project
2. Configure your data sources (similar to Looker connections)
3. Plan how your views will map to models
### 2. Convert Views to Models
Your Looker views will become Holistics models. Here's a simple example:
```tsx
// Your current Looker view
view: orders {
sql_table_name: public.orders ;;
dimension: order_id {
primary_key: yes
sql: ${TABLE}.id ;;
}
measure: total_amount {
type: sum
sql: ${amount} ;;
}
}
// Your new Holistics model
Model orders {
type: 'table'
data_source_name: 'warehouse' // Like your connection name
table_name: 'public.orders'
dimension order_id {
type: 'number'
definition: @sql {{ #SOURCE.id }};; // #SOURCE replaces ${TABLE}
}
measure total_amount {
type: 'number'
definition: @sql sum({{ amount }});;
}
}
```
Key differences:
- Use `#SOURCE` instead of `${TABLE}`
- Use `{{ field_name }}` instead of `${field_name}`
- Dimensions and measures use `definition` with `@sql` tag
For detailed conversion steps, see our [Views Migration Guide](/docs/from-others/looker/views-migration).
### 3. Convert Explores to Datasets
Your Looker explores will become Holistics datasets. The main difference is how joins are handled:
```tsx
// Your current Looker explore
explore: orders {
join: users {
sql_on: ${orders.user_id} = ${users.id} ;;
relationship: many_to_one
}
}
// Your new Holistics dataset
Dataset ecommerce {
models: [orders, users] // List all models (views) used
relationships: [
// '>' indicates many-to-one relationship
relationship(orders.user_id > users.id, true)
]
}
```
Key differences:
- No need to specify a root view
- Define relationships instead of explicit joins
- Holistics automatically determines optimal join paths
For detailed steps, see our [Explore Migration Guide](/docs/from-others/looker/explore-migration).
### 4. Set Up Access Control
Unlike Looker's LookML-based access grants, Holistics manages permissions through its UI:
- **Resource Access**: Control access to [Datasets](/docs/datasets#share-dataset) and [Dashboards](/docs/admin/permission-system#dashboard-level-permission)
- **Row-level Security**: Similar to Looker's access filters, but configured in the UI
- **Column-level Security**: Control access to specific fields
See our [Permission System documentation](/docs/admin/permission-system) for details.
### 5. Migrate Dashboards
After setting up your models and datasets:
1. Recreate your Looker dashboards in Holistics
2. Set up drill-downs and actions
3. Test and validate
For detailed steps, see our [Dashboard Migration Guide](/docs/from-others/looker/dashboard-migration).
## Detailed Migration Guides
For step-by-step instructions on migrating specific components:
- [Views Migration Guide](/docs/from-others/looker/views-migration)
- [Dimension Migration Guide](/docs/from-others/looker/dimension-migration)
- [Measure Migration Guide](/docs/from-others/looker/measure-migration)
- [Explore Migration Guide](/docs/from-others/looker/explore-migration)
## Additional Resources
- [Project Structure](/docs/development/project)
- [Quickstart](/docs/quickstart)
- [AML Documentation](/as-code/aml)
- [Permission System](/docs/admin/permission-system)
---
## Looker Migration Assistant Tool
The Looker Migration Assistant Tool helps you convert your LookML code into Holistics code, making your transition smoother and faster.
VIDEO
> **Note:** While the tool supports automatic migration for the items listed above, some advanced features or specific configurations within Looker views, explores, dimensions, or measures may not be fully supported.
## What can the Migration Assistant Tool convert?
- ✅ **Looker Views** → Holistics Models
- ✅ **Looker Dimensions** → Holistics Dimensions
- ✅ **Looker Measures** → Holistics Measures
- ⌛ **Looker Explores** → Holistics Datasets
_(Not yet supported, but you'll be directed to relevant documentation for manual migration.)_
## What is not supported?
- ❌ Looker Dashboards (both [LookML Dashboards](https://cloud.google.com/looker/docs/reference/lookml-dashboard-overview) and [User-defined Dashboards](https://cloud.google.com/looker/docs/dashboards))
- ❌ Looker [Native Derived Tables (NDTs)](https://cloud.google.com/looker/docs/creating-ndts)
- ❌ Looker definitions using [Liquid variable references](https://cloud.google.com/looker/docs/liquid-variable-reference)
For features that aren't supported, the tool will guide you to the appropriate Holistics documentation so you can complete the migration manually.
---
This tool is here to make your migration journey as smooth as possible. For more details and step-by-step guides, please refer to our documentation or reach out to our support team if you need assistance.
---
## Migrating Looker Models to Holistics
## High-level Overview
In Looker, a model is a collection of explores that share common configurations like connection, access grants, and datagroups. However, there is no direct equivalent concept in Holistics.
Instead, in Holistics:
- Each explore is converted to a separate dataset
- Each dataset is defined in its own file for better organization and management
- Common configurations are handled differently:
- Connections are defined per dataset
- Access control is managed through Holistics' permission system
- Data refresh is handled through persistence and schedules
For example, this Looker model:
```tsx
// in model.lkml
connection: "warehouse"
include: "views/*.view"
explore: orders {
join: users { ... }
}
explore: products {
join: categories { ... }
}
```
Would be converted to separate dataset files in Holistics:
```tsx
// in datasets/orders.dataset.aml
Dataset orders {
data_source_name: 'warehouse'
// ... dataset configuration
}
// in datasets/products.dataset.aml
Dataset products {
data_source_name: 'warehouse'
// ... dataset configuration
}
```
## Step-by-Step Migration Tutorial
### Step 1: Plan Dataset Structure
1. Identify all explores in your Looker model
2. Plan how to organize them into separate dataset files
3. Note any shared configurations that need to be handled
### Step 2: Create Datasets
1. Organize your datasets into separate files:
```
📁 datasets/
📄 orders.dataset.aml
📄 products.dataset.aml
📄 users.dataset.aml
```
2. For each explore in your Looker model, create a corresponding dataset following the [Explore Migration Guide](/docs/from-others/looker/explore-migration).
### Step 3: Handle Common Configurations
1. **Connection**: Set in each dataset
```tsx
// Looker
connection: "warehouse"
// Holistics - set in each dataset
Dataset orders {
data_source_name: 'warehouse'
}
```
2. **Access Control**: Unlike Looker's access_grants in model files, Holistics provides several permission options that are configured through the UI:
- **Resource Access Control**: Control access at [Dataset](/docs/datasets#share-dataset) and [Dashboard](/docs/admin/permission-system#dashboard-level-permission) levels
- **Row-level Permission**: Filter data based on user attributes. See [Row-level Permission](/docs/access-control/row-level-permission)
- **Column-level Permission**: Restrict access to specific columns. See [Column-level Permission](/docs/access-control/column-level-permission)
For a complete overview of Holistics' permission system, see our [Permission System documentation](/docs/admin/permission-system).
### Step 4: Test and Validate
1. Verify all datasets are working correctly
2. Check access controls are properly implemented
3. Test query performance
4. Compare results with original Looker model
## Detailed Feature Comparison
| LookML Parameter | Purpose | Support | Holistics Equivalent & Implementation |
|-----------------|---------|----------------------|-----------------------------------|
| **Structural Parameters** | | | |
| access_grants | Creates an access grant that limits access of LookML structures to only those users who are assigned an approved user attribute value. This parameter has the `user_attribute` and `allowed_values` subparameters. Once defined, you can use the required_access_grants parameter at the Explore, join, view, or field level to require the access grant to access those structures. | ✔️ (partially) | Holistics supports several permission options - Resource Access Control ([Dataset](/docs/datasets#share-dataset) and [Dashboard](/docs/admin/permission-system#dashboard-level-permission)) - [Role-level Perrmission](/docs/access-control/row-level-permission) - [Column level permission](/docs/access-control/column-level-permission) |
| explore | Define the Explore | ✅ | Holistics **Dataset** is the equivalent of Looker Explore. To migrate Explore, you can use the [Explore Migration Guide](/docs/from-others/looker/explore-migration). |
| include | Adds files to a model | ❌ | Holistics does not require this parameter. |
| test | Creates a data test to verify your model's logic. This parameter has the `explore_source` and `assert` subparameters. | ❌ | Holistics does not support data test yet. |
| **Display Parameters** | | | |
| label (for model) | Changes the way a model appears in the Explore menu | ❌ | |
| **Filter Parameters** | | | |
| case_sensitive (for model) | Specifies whether filters are case-sensitive for a model | ✅ | Users can control this with [AQL operator](/reference/aql/operator#text) |
| **Query Parameters** | | | |
| connection | Changes the database connection for a model | ✅ | using data_source_name property in Dataset as `data_source_name: 'source_name'`|
| datagroup | Creates a datagroup-caching policy for a model. This parameter has the `label`, `description`, `max_cache_age`, and `sql_trigger` subparameters. | ❌ | Not suppported yet. |
| fiscal_month_offset | Specifies the month your fiscal year begins (if it differs from the calendar year) | ❌ | Not support yet |
| persist_for (for model) | Changes the cache settings for a model| ❌ | Not support yet |
| persist_with (for model) | Specifies the datagroup to use for the model's caching policy | ❌ | Not support yet |
| week_start_day | Specifies the day of the week on which week-related dimensions should start | ✅ | Holistics [Week Start Day](/docs/datetimes/week-start-day) |
| **Visualization and Formatting Parameters** | | | |
| map_layer (for model) | Creates custom maps to be used with map_layer_name | ✅ | Holistics [Custom Map](/guides/map/create-custom-map) |
| named_value_format | Creates a custom value format to be used with value_format_name. This parameter has the value_format and strict_value_format subparameters. | ❌ | Not supported yet. |
---
## Nested Aggregation: Looker vs Holistics
## Introduction
Every Looker developer has hit this wall: you need a median of a sum, and suddenly you're creating yet another derived table. Your model grows by another view. Your field picker gets more cluttered. And your end users? They're back to filing tickets instead of exploring data themselves.
This post explains why nested aggregations are painful in Looker, examines the workarounds, and shows how Holistics handles them differently.
:::info TLDR
Looker **handles nested aggregation by creating derived tables, which limits self-service capabilities and causes model bloat**. Holistics allows you to define metrics with nested aggregation logic directly, and handles the nested SQL automatically.
:::
| Feature | Looker (LookML) | Holistics (AQL) |
|---------|-----------------|-----------------|
| **Logic Placement** | Spread across Views and Derived Tables | Encapsulated entirely within the Metric definition |
| **Grain Control** | Fixed at the View level (Hardcoded dimensions) | Dynamic: metric defines its own inner grain |
| **Self-Service** | Limited: new dimensions require new derived tables | High: dimensions can be changed freely in the UI |
| **Code Bloat** | High (Dozens of "Fact" views for different grains) | Low (One metric handles multiple dimensional contexts) |
## The Problem: Median of Sum (Aggregate of Aggregate)
Here's a common business question that's surprisingly hard to answer in Looker:
> For **each country**, give me **median revenue per buyer**
| Country | Median Revenue Per Buyer |
|---------|--------------------------|
| United States | $1000 |
| Canada | $600 |
| Mexico | $300 |
In a standard Looker view, your code might look like this:
```javascript
view: orders {
dimension: country { type: string; sql: ${TABLE}.country ;; }
dimension: user_id { type: string; sql: ${TABLE}.user_id ;; }
measure: revenue {
type: sum
sql: ${TABLE}.revenue ;;
}
}
```
The problem? You cannot create a measure of `type: median` that points to a measure of `type: sum`. Looker won't let you nest aggregations this way.
## The SQL Solution
Because `SUM` and `MEDIAN` operate on different "grains" of data, SQL requires two distinct steps:
1. **Step 1 (inner sum):** Group by User and Country to get the `SUM(revenue)`.
2. **Step 2 (outer median):** Take the `MEDIAN` of those results, grouped only by Country.
Standard SQL expresses this through subqueries/CTE:
```sql
-- For each country, display median revenue per user
WITH cte AS (
SELECT
country,
user_id,
SUM(revenue) AS revenue
FROM orders
GROUP BY 1,2
)
SELECT
country,
percentile_cont(0.5) WITHIN GROUP (ORDER BY revenue) AS median_revenue_per_user
FROM cte
GROUP BY 1;
```
## Looker's Solution: Derived Table
Looker *can* generate subqueries. But it doesn't do it automatically for measure-on-measure calculations.
Looker's approach is to **"dimensionalize" the inner measure**. You first turn the sum into a dimension using a Native Derived Table (NDT) or Persistent Derived Table (PDT), then aggregate over that dimension. The pattern looks like this:
1. Create a new view with a derived table containing the inner aggregation
2. Turn the inner sum into a dimension
3. Define a measure that calculates the median over that dimension

```sql
view: user_order_facts {
derived_table: {
sql:
SELECT
country,
user_id,
SUM(revenue) AS revenue
FROM orders
GROUP BY 1,2
;;
}
dimension: country { type: string }
dimension: revenue {
type: number
sql: ${TABLE}.total_revenue ;;
}
measure: median_revenue_per_user {
type: median
sql: ${revenue} ;;
}
}
```
### The Tradeoff: Reduced Self-Service Flexibility
The downside of this approach is flexibility. Once the derived table is created, its dimensions are fixed:
- **Hard-coded dimensions:** The `country` dimension is baked into the derived table. If a user wants to slice the same median by "Marketing Source" or "Age Group," a developer must create a new derived table.
- **Model bloat:** You end up with a "Fact View" for every nested grain. Dozens of nearly-identical views clutter the field picker.
- **No dynamic filtering:** Filters applied to the main explore don't automatically pass through to the derived table's inner SQL without complex `templated_filters`.
## Holistics' Approach: Encapsulate the Nested Logic in the Metric
If we look at the original SQL, notice that the "median revenue per user" logic is spread across the two nested queries (the inner CTE and the outer SELECT).

**What if we could encapsulate the entire nested logic into the metric definition itself?** What if there's a way to define the highlighted part of the SQL into a measure definition?
```sql
WITH cte AS (
SELECT
country,
-- highlight-next-line
user_id,
-- highlight-next-line
SUM(revenue) AS revenue
-- highlight-next-line
FROM orders
GROUP BY 1,2
)
SELECT
country,
-- highlight-next-line
percentile_cont(0.5) WITHIN GROUP (ORDER BY revenue) AS median_revenue_per_user
FROM cte
GROUP BY 1;
```
That's what AQL (Analytics Query Language) enables in Holistics. Instead of spreading logic across multiple Looker views (Holistics models), you define the metric once, including its inner grain:
```aml
Model orders {
dimension user_id { .. }
dimension country { .. }
dimension created_at { .. }
dimension revenue { ... }
measure median_rev_per_user {
label: 'Median Revenue per User'
// highlight-start
definition: @aql orders
| group(orders.user_id) // Step 1: Inner grain
| select(sum(orders.revenue)) // Step 2: Inner aggregation
| median() ;; // Step 3: Outer aggregation
// highlight-end
}
}
```
**How it works:** AQL uses a pipe (`|`) syntax where each step transforms the data. The engine parses this logic tree, recognizes you're asking for an aggregate-of-aggregate, and automatically generates the necessary CTEs or subqueries.

The AQL metric also "knows" its own inner grain. When a user picks any dimension (country, marketing source, age group), the engine injects that dimension throughout the nested subqueries automatically.
**See it in action:** The video below shows the "Median Revenue per User" metric working with any dimension, no additional configuration required.
## Conclusion
We presented the challenges that Looker developers faced when working with nested aggregation in Looker, and also how Holistics solved this with AQL.
This issue highlights a generational shift in semantic modeling. Looker/LookML was built to be a direct abstraction of a single SQL query. It's powerful, but it's **literal**.
Modern engines like Holistics (AQL) treat metrics as **semantic objects** rather than just SQL snippets. By allowing metrics to define their own internal grain and reference one another, AQL overcomes the nested aggregation limit that Looker developers have faced for years.
For the end user, this means true self-service: the ability to slice complex, nested metrics by any dimension at any time.
## Related Resources
- [AQL Overview](/as-code/aql) - Learn more about Analytics Query Language
- [Nested Aggregation in AQL](/as-code/aql/cookbook/aql-nested-aggregation) - Detailed guide on nested aggregations
- [Metrics by Example](/as-code/aql/cookbook/metrics-by-example) - See more metric definition patterns
---
## Migrating Looker Views to Holistics
## Overview
A view in Looker corresponds to a model in Holistics.
A view in Looker represents a database table or a derived table. In Holistics, this is equivalent to a [Table Model or Query Model](/reference/aml/model). Holistics Model has the equivalent common concepts like table name, dimension, and measure.

## Step-by-Step Migration Tutorial
### Step 1: Identify View Type
Determine if the view is based on a table or a derived query:
- Table-based view → Table Model
- Derived view → Query Model
- Extended view → Extended Model
:::note
Looker has [2 types of derived views](https://cloud.google.com/looker/docs/derived-tables):
- **SQL-based derived table version**: a derived view built from a SQL query
- **Native derived table version**: a derived view built from an Explore
Holistics only supports **SQL-based derived view** conversion.
:::
### Step 2: Create Model Structure
#### For table-based views
```tsx title="views/orders.view.lkml"
// Looker
view: orders {
sql_table_name: public.orders ;;
# fields defined here...
}
```
```tsx title="models/orders.model.aml"
// Holistics
Model orders {
//highlight-next-line
type: 'table'
//highlight-next-line
table_name: 'public.orders'
// fields defined here...
}
```
For more details on table model, see [Table Model](/reference/aml/table-model).
#### For SQL-based derived views
```tsx title="views/active_users.view.lkml"
// Looker
view: active_users {
derived_table: {
sql: SELECT
user_id,
COUNT(DISTINCT order_id) as order_count
FROM orders
WHERE status = 'completed'
GROUP BY 1 ;;
}
}
```
```tsx title="models/active_users.model.aml"
// Holistics
Model active_users {
//highlight-next-line
type: 'query'
data_source_name: 'warehouse'
query: @sql
SELECT
user_id,
COUNT(DISTINCT order_id) as order_count
FROM orders
WHERE status = 'completed'
GROUP BY 1;;
}
```
For more details on query model, see [Query Model](/reference/aml/query-model).
#### For extended views
```tsx title="views/extended_orders.view.lkml"
// Looker
view: extended_orders {
extends: [orders]
dimension: is_high_value {
type: yesno
sql: ${amount} > 1000 ;;
}
}
```
```tsx title="models/extended_orders.model.aml"
// Holistics
Model extended_orders = orders.extend({
label: 'Extended Orders',
dimension is_high_value {
type: 'truefalse'
definition: @sql {{ amount }} > 1000;;
}
})
```
For more details on extended model, see [AML Extend](/reference/aml/extend).
### Step 3: Add Data Source Name to Your Models
Unlike Looker where connections are defined at the model level, Holistics requires explicitly specifying the data source for each model. Check the `connection` property in your Looker model file and add it to your Holistics model:
```tsx title="Looker model file"
connection: "warehouse" // Defined once at model level
include: "views/*.view"
explore: order_items {
join: products { ... }
join: orders { ... }
}
```
```tsx title="Holistics model file"
Model order_items {
data_source_name: 'warehouse' // Required in each model
}
```
For more details on connection differences, see [Conceptual Differences: Data Source Connection](/docs/from-others/looker/conceptual-differences#data-source-connection).
### Step 4: Migrate Fields
After setting up the model structure, migrate the fields following these dedicated guides:
- For dimensions, follow the [migrating dimension guide](/docs/from-others/looker/dimension-migration)
- For measures, follow the [migrating measure guide](/docs/from-others/looker/measure-migration)
These guides provide detailed instructions for converting different types of fields and their properties.
### Step 5: Handle Persistence (for derived views)
**In Looker**, persistence is configured within the derived table:
```tsx title="views/order_summary.view.lkml"
view: order_summary {
derived_table: {
sql: SELECT
user_id,
COUNT(*) as order_count,
SUM(amount) as total_amount
FROM orders
GROUP BY user_id ;;
interval_trigger: "24 hours"
}
}
```
**In Holistics**, we handle this with Query Model persistence and schedules: Define the persistence in the model's definition, and set up the schedule in the `schedules.aml` file.
```tsx title="models/order_summary.model.aml"
Model order_summary {
type: 'query'
query: @sql
SELECT
user_id,
COUNT(*) as order_count,
SUM(amount) as total_amount
FROM orders
GROUP BY user_id;;
persistence: FullPersistence {
schema: 'persisted'
view_name: 'order_summary'
}
}
```
```tsx title="schedules.aml"
const schedules = [
// Refresh order_summary daily at midnight
Schedule {
models: [order_summary],
cron: '0 0 * * *' // daily at midnight
}
]
```
For more details on persistence options in Holistics, see our [Persistence documentation](/docs/persistence).
### Step 5: Test and Validate
1. Verify all fields are correctly migrated
2. Check data types are properly mapped
3. Test derived calculations
4. Compare query results with Looker
### Step 6: Continue migrating models and explores
For more details on migrating models and explores, see the following guides:
- [Model Migration Guide](/docs/from-others/looker/model-migration)
- [Explore Migration Guide](/docs/from-others/looker/explore-migration)
## Detailed Feature Comparison
| LookML Parameter | Purpose | Support | Holistics Equivalent & Implementation |
|-----------------|---------|----------------------|-----------------------------------|
| **Structural Parameters** | | | |
| drill_fields | Control which fields to show when drilling/clicking a field | ❌ | Holistics currently supports drilling between dashboards as in [Drill Through](/docs/interactions/drill-through), but we haven't supported field-level control yet |
| extend (for view) | Build a new View based on an existing View | ✅ | Holistics [AML Extend](/reference/aml/extend) |
| extension | Force using `extends` on a view | 🧪 (partial) | Holistics doesn't support this exact concept but we can rebuild this via [Function](/reference/aml/func) |
| include | Add files to a view | | |
| test | Creates a data test to verify your model's logic. | ❌ | Holistics doesn't support this feature yet. |
| set | Defines a set of dimensions and measures to be used in other parameters | ❌ | Holistics doesn't support this feature yet. |
| view | Create a view | ✅ | Holistics [data model](/reference/aml/model) |
| **Display Parameters** | | | |
| label | The label of the view | ✅ | Holistics model's label `label: 'Your Model Label'` |
| fields_hidden_by_default | When set to yes, hides all fields in the view by default. | 🧪 (partial) | Holistics doesn't support this exact concept but we can [extend](/reference/aml/extend#override-nested-properties) a Model to hide fields |
| **Field Parameters** | | | |
| suggestions (for view) | Enables or disables suggestions for all dimensions on this view | ❌ | |
| **Query Parameters** | | | |
| required_access_grants (for view) | Limits access to the view to only users whose user attribute values match the access grants | ❌ | |
| sql_table_name (for view) | Changes the SQL table on which a view is based | ✅ | - `data_source_name`: for specifying the data source name- `table_name`: for specifying the table name in TableModel- For dynamic schema, see [our doc here](/docs/development/dynamic-schema)|
| **Derived Table Parameters** | | | |
| derived_table | A table based on a query | ✅ | [Query Model](/reference/aml/query-model) |
| sql (for derived_table) | Declares the SQL query for a derived table | ✅ | Using `query: @sql your_query_here ;;` parameter in [Query Model](/reference/aml/query-model) |
| explore_source | Defines a native derived table based on an [Explore](https://cloud.google.com/looker/docs/creating-ndts) | ❌ | Holistics doesn't support this feature yet. |
| sql_create | Defines a SQL CREATE statement to create a PDT on a database dialect that requires custom DDL commands | ✅ | [Custom Persistence DDL](/docs/persistence#custom-persistence-ddl)
| datagroup_trigger | Specifies the datagroup to use for the PDT rebuilding policy | ✔️ (partial) | Holistics doesn't have the exact concept of [datagroup](https://cloud.google.com/looker/docs/reference/param-model-datagroup), but we can set [schedule trigger](/docs/persistence#2-create-persistence-schedule) on individual query model |
| interval_trigger | Specifies the interval to use for the PDT rebuilding policy | ✅ | Using Holistics schedule trigger defined in [`schedules.aml`](/docs/persistence#2-create-persistence-schedule) file |
| sql_trigger_value | Specifies the condition that causes a PDT to be regenerated | ❌ | Not supported yet in Holistics. |
| persist_for (for derived_table) | Sets the maximum age of a PDT before it is regenerated | ✔️ (partial) | Users cannot control this in Holistics yet, and the default value is [24 hours](/docs/persistence#when-will-the-persisted-table-be-invalidated) |
| increment_key | The increment_key specifies the time increment for which fresh data should be queried and appended to the PDT. | ✅ | Using `increment_column: 'your_column'` parameter in [`persistence: IncrementalPersistence { }`](/docs/persistence#incremental-persistence) |
| **Field Reference Parameters** | | | |
| dimension | list of dimensions | ✅ | [Dimension migration guide](/docs/from-others/looker/dimension-migration) |
| measure | list of measures | ✅ | [Measure migration guide](/docs/from-others/looker/measure-migration) |
| dimension_group | Creates several time-based dimensions at the same time | ✅ | Holistics has several equivalent options: - [Date Drill](/docs/interactions/date-drills) - [Date Part](/docs/datetimes/date-parts) - [Time Intelligence Functions](/reference/aql/time-intelligence-functions) |
| sets | Define a set of dimensions and measures to be used in other parameters | ✅ | Holistics doesn't have the exact set concept, but we a wide range of reusability features such as: - [Constant](/reference/aml/constant)- [Function](/reference/aml/func)- [Extend](/reference/aml/extend)- [String Interpolation](/reference/aml/string-interpolation) |
| parameter, filter for Templated filters | Creates a filter-only field that can be used to filter Explores, Looks, and dashboards but that cannot be added to a result set. | ✅ | [Query Parameter](/docs/query-parameters) |
(*) Looker Parameters that are not mentioned in this table are generally not supported
---
## From Power BI to Holistics
:::info
This document was written in **June 2026**, and may have become inaccurate as Holistics and Power BI continue to improve their products.
:::
If you come from a Power BI background, this page maps Power BI concepts to their Holistics equivalents and explains where the two platforms diverge in philosophy.
## TL;DR
| Dimension | Power BI | Holistics |
| --------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Modeling language** | DAX + TMDL | [AMQL](/as-code/amql/) ([AML](/reference/aml/) + [AQL](/as-code/aql/learn-in-30-minutes)) |
| **ETL layer** | Power Query (M) inside the file | [Query models](/reference/aml/query-model) (SQL in `.model.aml`) + upstream dbt/SQL |
| **Project format** | `.pbix` (binary) or `.pbip` (as-code) | AMQL |
| **Version control** | Strong for model, weak for report | [Uniform AMQL diffs](/docs/git-version-control/) across models, datasets, and dashboards |
| **Authoring tool** | Power BI Desktop (Windows-only) | [Web IDE](/docs/development/aml-studio) or [local development](/docs/development/local-agentic-development) (cross-platform) |
| **Compute engine** | VertiPaq in-memory (Import) or DirectQuery | SQL pushdown to [warehouse](/docs/connect/databases-supported) |
| **Reusability** | Per-workspace semantic models | [Reusable-first philosophy](/as-code/aml/reusability-overview):• Reusable models across datasets• Reusable metrics across dashboards• Reusable dimensions and measures within a model• Modules to organize and share code |
| **Self-service** | Q&A, "Explore", report consumption | [Dataset Explore](/docs/data-exploration/), [Canvas Dashboards](/docs/dashboards/) |
## Power BI vs. Holistics
Power BI bundles modeling and visualization in one `.pbix` (or `.pbip`) project.
Holistics separates them: **semantic layer** (Models + Datasets) vs. **presentation layer** (Dashboards).
Core mapping:
- Power BI **table** → Holistics **model**
- Power BI **semantic model** → Holistics **dataset**
- Power BI **report** → Holistics **dashboard**
| Power BI artifact | Holistics equivalent | Key difference |
| ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Power Query (M)](https://learn.microsoft.com/en-us/power-query/power-query-what-is-power-query) | [Query model](/reference/aml/query-model) (`.model.aml`) | M materializes into VertiPaq on refresh; query model compiles SQL at runtime. |
| [Table](https://learn.microsoft.com/en-us/power-bi/transform-model/desktop-tutorial-create-calculated-columns) (in Semantic Model) | [Model](/reference/aml/model) (`.model.aml`) | Power BI tables hold imported data; Holistics models are logical definitions only. |
| [Semantic Model](https://learn.microsoft.com/en-us/power-bi/connect-data/service-datasets-understand) (formerly Dataset) | [Dataset](/docs/datasets) (`.dataset.aml`) | Power BI semantic model is workspace-scoped; Holistics datasets reuse models freely. |
| [Relationship](https://learn.microsoft.com/en-us/power-bi/transform-model/desktop-create-and-manage-relationships) | [Relationship](/docs/datasets/dataset-relationships) | Holistics resolves [join paths](/docs/joins/how-joins-work) from selected fields. |
| [Measure](https://learn.microsoft.com/en-us/power-bi/transform-model/desktop-measures) (DAX) | [Metric](/reference/aml/metric) (AQL / SQL) | DAX uses filter context; AQL uses explicit dimension scope. |
| [Calculated column](https://learn.microsoft.com/en-us/power-bi/transform-model/desktop-calculated-columns) (DAX) | [Dimension](/reference/aml/field) (AQL / SQL) | Calculated columns materialize in VertiPaq; dimensions compile inline into SQL. |
| [Report](https://learn.microsoft.com/en-us/power-bi/create-reports/) (`.Report` folder) | [Canvas Dashboard](/docs/dashboards/) | Power BI reports bind to one semantic model; Holistics dashboards span multiple datasets. |
| [Dashboard](https://learn.microsoft.com/en-us/power-bi/create-reports/service-dashboards) | [Canvas Dashboard](/docs/dashboards/) | Power BI dashboards exist only in Power BI Cloud (Service); Desktop has no dashboard concept. Holistics has one unified Canvas Dashboard everywhere. |
| [Workspace](https://learn.microsoft.com/en-us/power-bi/collaborate-share/service-create-workspaces) | [Module](/reference/aml/module) | Power BI workspace = deployment + sharing; Holistics splits these into modules + [permissions](/docs/access-control/row-level-permission). |
| `.pbip` project folder | AMQL repository | Power BI splits TMDL + JSON; Holistics uses one uniform AMQL syntax for everything. |
### Modeling language
DAX and AQL take fundamentally different paths to the same goal: a reusable analytics language for the modern BI stack.
**AQL is SQL++**. Holistics leverages SQL as its foundation and builds common analytic functions ([running total](/docs/running-total), [percent of total](/docs/percent-of-total), [period over period](/docs/period-comparison), [nested aggregation](/as-code/aql/learn/nested-aggregation)) on top of it. Everything compiles down to SQL that runs on your warehouse, but with semantic awareness baked in: joins, metrics, period comparisons, drill paths, and timezone handling.
So that when writing AQL, you don't need to worry about rewriting join paths, hand-rolling date logic, juggling SQL dialect differences across warehouses. **You just need to focus on the core logic.**
**DAX takes a different approach.** It's a proprietary expression language built around an in-memory engine, with its own syntax, its own execution model, and its own filter-context semantics.
DAX is powerful (and indeed we learnt many things from DAX). But disconnected from the SQL that already lives in your warehouse, your dbt project, and your team's everyday work. Every new hire pays a learning tax that doesn't transfer anywhere else.
| Aspect | Power BI | Holistics |
| ---------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Languages** | DAX (logic) + TMDL (schema) | One language: [AMQL](/as-code/amql/) = [AML](/reference/aml/) + [AQL](/as-code/aql/learn-in-30-minutes) |
| **Mental model** | Filter context (implicit) | Dimension scope (explicit) |
| **Time intelligence** | Built-in DAX functions, requires marked Date table | Native [period functions](/as-code/aql/learn/time-comparisons) (`relative_period`, `year()`, `quarter()`) on any timestamp column |
| **Nested aggregation** | `CALCULATE` + iterators | First-class via [metric](/reference/aml/metric)/[measure](/reference/aml/field) and dimension scope |
| **Compiles to** | VertiPaq query plan | SQL |
| **SQL fallback** | Not available (DAX only) | SQL measures supported alongside AQL |
Modeling power in Holistics comes from:
- **[AMQL](/as-code/amql/)**: one declarative language for models, datasets, dashboards, **and** measures.
- **Explicit dimension scope**: a metric declares what it groups by.
- Behavior is predictable across dashboards.
- No surprise re-evaluation from filter context.
- **Native [period functions](/as-code/aql/learn/time-comparisons)**: `relative_period`, `year()`, `quarter()`, `month()` work on any timestamp column.
- No special Date table required.
- **First-class [nested aggregation](/as-code/aql/learn/nested-aggregation)** via metric/measure composition.
- **SQL fallback**: when AQL isn't a fit, write SQL directly inside a measure or query model.
#### What this means for a team migrating off Power BI
- Your team's SQL skills still count. AQL builds on SQL instead of replacing it.
- Measures stay as readable SQL, so the logic still makes sense outside Holistics.
- Most analysts get productive in AQL within a few days.
- If a metric is easier to write in plain SQL, just write it in SQL. No need to fight the language.
#### Example: year-over-year sales
In DAX:
```dax
Sales YoY =
VAR CurrentSales = [Total Sales]
VAR PriorSales =
CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Date'[Date]))
RETURN
DIVIDE(CurrentSales - PriorSales, PriorSales)
```
In Holistics AQL:
```aml {5,13}
metric sales_last_year {
label: 'Sales Last Year'
type: 'number'
definition: @aql
total_sales | relative_period(orders.created_at, interval(-1 year))
;;
}
metric sales_yoy {
label: 'Sales YoY % Change'
type: 'number'
definition: @aql
safe_divide((total_sales - sales_last_year) * 100, sales_last_year)
;;
}
```
Or in SQL, for teams that want to stay in SQL:
```aml {4}
measure total_sales {
label: 'Total Sales'
type: 'number'
definition: @sql SUM({{ amount }});;
}
```
### ETL layer
Power BI does ETL inside the file with Power Query (M), which materializes data into VertiPaq on refresh.
Holistics does light transforms in [query models](/reference/aml/query-model) (SQL inside `.model.aml`) and pushes heavy ETL upstream to dbt or SQL jobs.
This keeps the BI layer thin and avoids the "logic trapped in the BI tool" problem. Warehouse tables and dbt models stay reusable by any consumer, not just Holistics.
| Concern | Power BI | Holistics |
| --------------------- | --------------------------------------- | --------------------------------------------------------------- |
| **In-tool transform** | Power Query (M) → VertiPaq | Query model (SQL in `.model.aml`) → compiled into runtime query |
| **Language** | M (proprietary) | SQL |
| **Materialization** | Imported into VertiPaq on refresh | None by default; runs live against the warehouse |
| **Heavy ETL** | M scripts inside `.Dataset/definition/` | [dbt](/docs/dbt-integration/) / SQL upstream |
| **Cross-tool reuse** | Limited (Dataflows or duplicate models) | Native (any tool can read the warehouse / dbt models) |
| **Version control** | TMDL + M diffs | AMQL diffs |
Transformation in Holistics comes from:
- **[Query models](/reference/aml/query-model)**: `.model.aml` files whose source is a SQL `SELECT`.
- Composed into the runtime query.
- No separate refresh step, no second copy of the data.
:::tip
We offer the **[dbt integration](/docs/dbt-integration/)** to sync metadata between dbt models and Holistics models.
:::
#### Example: an "active users only" view
```aml title="active_users.model.aml" {7-11}
Model active_users {
type: 'query'
data_source_name: 'warehouse'
dimension ...
query: @sql
select id, email, signup_at
from raw.users
where deleted_at is null
;;
}
```
### Version control
**Power BI** is text-serializable through `.pbip`, but only halfway. The semantic model side (`.Dataset/`, TMDL) gets clean Git diffs. The report side (`.Report/report.json`) is nested JSON that merge-conflicts often and usually breaks if hand-edited.
**Holistics** is text-native end-to-end. Models, datasets, dashboards, charts, and filters are all uniform AMQL text. The standard Git PR workflow applies to every layer, not just the semantic model.
| Aspect | Power BI (`.pbip`) | Holistics (AMQL) |
| ------------------------- | -------------------------------------------------- | ----------------------------------------- |
| **Semantic model code** | TMDL (Git-friendly) | AMQL (Git-friendly) |
| **Report/dashboard code** | Nested JSON (fragile diffs) | AMQL (clean diffs) |
| **Authoring tool** | Power BI Desktop (thick client) + VS Code for TMDL | Web IDE or local IDE (single language) |
| **Deployment** | Fabric Git Integration + Deployment Pipelines | Native dev/prod environments + PR merge |
| **CI validation** | Tabular Editor BPA + custom scripts | `holistics aml validate` + Validation API |
Here is example of project layouts side by side:
```text
power-bi-project/ holistics-project/
project.pbip models/
project.Dataset/ tables/
definition/ orders.model.aml
model.tmdl users.model.aml
tables/orders.tmdl queries/
tables/users.tmdl active_users.model.aml
expressions.tmdl (M code)
project.Report/ datasets/
definition.pbir ecommerce.dataset.aml
report.json marketing.dataset.aml
dashboards/
sales.page.aml
performance.page.aml
```
Version control in Holistics comes from:
- **Uniform AMQL text** for everything: models, datasets, dashboards, charts, filters.
- One language, one syntax, clean diffs across the whole stack.
- **[Git-backed projects](/docs/git-version-control/)** with native GitHub, GitLab, Bitbucket integration.
- **[Dev/prod environments](/docs/development/dev-prod-mode)** built in.
- Edit on a dev branch, preview in the UI, publish to prod.
#### A typical dashboard workflow in Holistics
1. **Branch off `master`**
2. **Edit AMQL** (Web IDE or local IDE with `holistics sync-code`). Changes push to a Holistics dev branch within seconds.
3. **Preview in Holistics UI** on the dev branch. Iterate until the dashboard looks right.
4. **Commit and push**
5. **Open a Pull Request**
6. **CI runs validation** to check syntax automatically:
- `holistics aml validate` locally before push, or [Validation API](/docs/continuous-integration/validation-api) in CI on every PR.
7. **Review and approve**. Diffs are clean AMQL, so reviewers can read the actual change instead of nested JSON.
8. **Merge to `master`**. Holistics deploys to production.
```diagram
╭─────────────╮ ╭─────────────╮ ╭─────────────╮ ╭─────────────╮
│ Branch off │────▶│ Edit AML │────▶│ Preview in │────▶│ Commit + │
│ main │ │ (sync-code) │ │ Holistics │ │ push │
╰─────────────╯ ╰─────────────╯ ╰─────────────╯ ╰──────┬──────╯
│
╭──────────────────────────────────────────────────────────╯
▼
╭─────────────╮ ╭─────────────╮ ╭─────────────╮ ╭─────────────╮
│ Open PR │────▶│ CI: │────▶│ Review + │────▶│ Merge + │
│ │ │ validate │ │ approve │ │ auto-publish│
╰─────────────╯ ╰─────────────╯ ╰─────────────╯ ╰─────────────╯
```
See the full setup guide at [Local development workflow](/docs/development/local-agentic-development) and [PR Workflow for GitHub](/docs/continuous-integration/github-pr-workflow).
### Authoring tool
Power BI authoring is anchored on a Windows-only desktop client.
Holistics offers a browser-based Web IDE for analysts and a fully [local development workflow](/docs/development/local-agentic-development) for engineers who prefer their own editor and coding agents.
| Aspect | Power BI | Holistics |
| ---------------------- | ------------------------------------------------ | -------------------------------------------------------- |
| **OS support** | Windows-only (Desktop) | Cross-platform (Web IDE and local IDE) |
| **Authoring surface** | Thick client + TMDL via VS Code/Tabular Editor | Web IDE or any local IDE (VS Code, Cursor, JetBrains, …) |
| **AI / agent support** | Copilot in Fabric (chat assistant) | First-class: MCP server, skills, and live AQL execution |
| **Live sync** | Manual save → publish, or Fabric Git Integration | `holistics sync-code` continuous bidirectional sync |
| **Local validation** | Tabular Editor "Best Practices Analyzer" | `holistics aml validate` CLI + Validation API in CI |
| **Report authoring** | Desktop-only (no text editor support) | AMQL text or visual Canvas editor |
Authoring options in Holistics come from:
- **Web IDE**:
- Cross-platform (any modern browser).
- Auto-completion, inline data preview, and visual chart configuration for analysts.
- No install.
- **[Local development](/docs/development/local-agentic-development)** with your own IDE + AI agents:
### Compute engine
**Power BI** defaults to importing data into an in-memory VertiPaq engine and refreshing on a schedule. That insulates dashboards from warehouse load, but at the cost of an extra copy of the data, a refresh pipeline to maintain, and size limits to plan around.
**Holistics** pushes every query down to your data warehouse. There is no separate copy of the data, no scheduled refresh to babysit, and no size cap beyond what the warehouse itself supports. Dashboards inherit the warehouse's freshness and concurrency: if the warehouse is fast, dashboards are fast.
| Aspect | Power BI | Holistics |
| --------------------- | ----------------------------------------------- | ------------------------------------------------- |
| **Storage** | VertiPaq in-memory (Import) or live DirectQuery | None; queries hit the warehouse directly |
| **Freshness** | Refresh-bound (Import) or live (DirectQuery) | Always live |
| **Performance lever** | More VertiPaq memory, model optimization | Warehouse compute + caching + aggregate awareness |
| **Size limits** | 1 GB (Pro), up to 400 GB (Premium) | Warehouse-bound |
| **Operational load** | Refresh pipeline + capacity management | Inherits warehouse load |
Performance from Holistics comes from:
- **Warehouse compute** (scales independently of Holistics).
- **[Data caching](/docs/performance/data-caching)** inside Holistics.
- **[Aggregate awareness](/docs/aggregate-awareness)** optimizes long-running queries by persisting pre-aggregated tables in the warehouse and routing queries to them automatically.
- **No refresh pipeline to maintain**: data is as fresh as the warehouse. Hence:
- No second copy to manage.
- Turning warehouse to the **single source of truth**.
---
## Migrate from Power BI to Holistics
This is the overview of the migration process from Power BI → Holistics:
1. Convert your `.pbix` file to a `.pbip` project
By converting to a `pbip` project, the semantic model is exposed in TMDL and measures are exposed in DAX
In Power BI, go to `File > Save As > Power BI Project (*.pbip)`
2. [Connect Holistics to your data warehouse](/docs/connect/)
3. Migrate the project one concept at a time:
- (Optional) Move heavy ETL out of Power Query (M) into upstream SQL or dbt
- Convert PBI **tables** to Holistics models (table or query type)
- Convert **relationships** to Holistics dataset relationships
- Convert **DAX measures** to AQL or SQL metrics
- Convert **calculated columns** to dimensions
- Rebuild **reports** as Holistics dashboards
4. Validation:
- Compare a sample of measure values between PBI and Holistics
- Re-create one report end-to-end and review with a stakeholder before scaling out
:::note Items that need manual migration
A few Power BI features map to Holistics concepts that live outside the `.pbip` project, so they can't be lifted from TMDL or JSON. Plan to re-create them manually:
| Power BI | Holistics equivalent | Notes |
| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- |
| [Data alerts](https://learn.microsoft.com/en-us/power-bi/create-reports/service-set-data-alerts) | [Alerts](/docs/delivery/data-alert) | Re-create thresholds and recipients in Holistics. |
| [Scheduled refresh / subscription](https://learn.microsoft.com/en-us/power-bi/connect-data/refresh-data) | [Schedule & email reports](/docs/delivery/export-data) | Holistics schedules send dashboard snapshots; no warehouse refresh job needed. |
| [Embed in app / Publish to web](https://learn.microsoft.com/en-us/power-bi/developer/embedded/embedded-faq) | [Embedded analytics](/embedded) | Embed tokens and iframe URLs must be reissued. |
| [Share link / publish to web link](https://learn.microsoft.com/en-us/power-bi/collaborate-share/service-publish-to-web) | [Shareable link](/docs/delivery/shareable-links) | Generate fresh links in Holistics and update any docs / wikis that point to PBI. |
Each of these has a Holistics [REST API](/api/) you can use to bulk-create them from a CSV or script, which is the fastest path when you have dozens of alerts or embeds to migrate.
:::
## Project structure
If you're used to a `.pbip` folder layout, here's how the same project looks in Holistics:
```
power-bi-project/ holistics-project/
project.pbip models/
project.Dataset/ tables/
definition/ orders.model.aml
model.tmdl users.model.aml
tables/orders.tmdl queries/
tables/users.tmdl active_users.model.aml
expressions.tmdl (M code)
project.Report/ datasets/
definition.pbir ecommerce.dataset.aml
report.json marketing.dataset.aml
dashboards/
sales.page.aml
performance.page.aml
```
## Agentic migration process
With the above conceptual mapping, a coding agent with the [`holistics-migrate-power-bi`](https://github.com/holistics/skills/tree/main/plugins/holistics-migrate-power-bi) skill can handle migration smoothly.
### Prerequisites
Before you run the skill:
- A `.pbip` export of Power BI
- In Power BI Desktop, go to `File > Save As > Power BI Project (*.pbip)`.
- Turn off **Auto date/time** to keep `LocalDateTable_*` artifacts out of TMDL.
- A warehouse Holistics can [connect](/docs/connect/connect.md) to
- Holistics project set up for [local development](/docs/development/local-agentic-development)
- The [holistics-migrate-power-bi](https://github.com/holistics/skills/tree/main/plugins/holistics-migrate-power-bi) skill installed
### Run the migration
Open your Holistics project in the IDE, drop the `.pbip` folder alongside it, and send one prompt:
```
/holistics-migrate-power-bi migrate this power bi project into holistics
```
The workflow runs through the project in seven phases:
1. Inventory: read TMDL, DAX, relationships, report JSON; list warehouse schemas
2. Models: write one `.model.aml` per Power BI table, validate after each
3. Dataset: write `.dataset.aml` with models and relationships
4. Metrics: convert DAX to AQL
5. Dashboard: write `.page.aml` canvas dashboard with filters, visuals, and layout
6. Parity: run metrics across dimension combinations, compare totals against Power BI
7. Sync: `holistics sync-code` to push to Holistics
## Validation checklist
After the migration finishes, sanity-check the result before handing it to stakeholders:
- [ ] Project compiles cleanly (`holistics aml validate`)
- [ ] Dataset diagram view shows the same star schema as Power BI
- [ ] Each DAX measure has a corresponding AQL metric, and totals match for a known date range
- [ ] Role-playing date relationships (active vs inactive) resolve the same joins Power BI used
- [ ] Dashboard pages mirror the Power BI report's layout and totals
- [ ] Row-level security rules produce the same row counts per user
## Additional resources
- [Conceptual Differences](/docs/from-others/power-bi/conceptual-differences)
- [Project Structure](/docs/development/project)
- [Local development with AI agents](/docs/development/local-agentic-development)
- [Quickstart](/docs/quickstart)
- [AML Documentation](/as-code/aml)
---
## Branch Management
## Introduction
One of the biggest benefits of the Version Control feature is that your team members can work on different branches of the code at the same time to test new ideas without blocking one another or disrupting end-users with constant changes to the reports.
## Create branch
To start making changes to your analytics code base, you need to create a new branch in the **Development mode**. It is not possible to make changes directly to your Production environment.
From the dialogue, you can either create a new branch from the **master** branch to start from your production code or choose one of the development brands you are working on to experiment further.
Notes before creating a new branch:
- If you have a merge conflict on your current branch, you must resolve the conflict before you can create a new branch.
- If you have any uncommitted changes on the current branch, you must commit the changes on your current branch before creating a new branch.
Once you are in a new branch, you can start making changes using Holistics's Cloud IDE. Once you're happy with it, commit your changes.
You can make as many commits as you want. Similar to software development, it's recommended that you break your changes into multiple small, meaningful commits rather than a single large one.
## Delete branch
If there are branches that are no longer useful for development and you want to clean up them, you can delete them in branch management
However, do note that even if you delete a branch, if that branch still exists in another repository of another user and he (or she) pushes it back to System repository, it will be once again presented to your branch list.
The reason for this is that you can only delete branches in your user repository and system repository, you will be unable to touch other people's repository.
---
## Code Review Process
## Introduction
After [connecting to an external Git repository](/docs/git-version-control/external-git), to make full use of your new setup, you can enforce a pull request & code review process.
## Example workflow with Code Review
Aside from the steps in the [default workflow](/docs/git-version-control#high-level-workflow) with version control in Holistics, the code review process should happen before **Publish**.
In Development, after a data developer **Commit** their code, instead of clicking **Publish** to production right away, they should go through the code review process as follows:
1. The data developer goes to the external Git repository, and creates a Pull Request (PR), then asks for a review from another team member.
2. If the PR is approved, the developer merges the PR to the `master` / `main` branch. Then go back to Holistics Development and click **Publish** to Production.
3. If the reviewer requests some changes, the developer should go back to Holistics Development, modify as requested, and push again.
## How to enforce the Code Review process
Currently, Holistics **has not implemented** an internal code review feature similar to that of GitHub/GitLab, and users can publish directly to production right after committing.
To make sure the changes must go through a review before publishing, you can use the **Branch Protection** feature of GitHub / GitLab. For example, in GitHub:
1. Go to **your GitHub repository**. After that:
a. go to **Settings -> Branches -> Add branch protection rule**.

b. Input `master` to the **Branch name pattern** field, and select any protection rule you need.

c. Select **Require a pull request before merging**

d. Select **Do not allow bypassing the above settings**

2. Now you will not be able to to publish to production without first creating a Pull Request (PR). In this step, you **must use option** `Create a merge commit` instead of `Squash and merge` or `Rebase and merge`.
The reason is that our Git Actions depend on Commit History. Thus, if you use either `Squash and Merge` or `Rebase and merge` methods, the commit history is modified so that Holistics cannot detect if the published commit is merged to master or not.

3. After the PR has been successfully merged to `master`, you have 2 options to Publish your changes to Production:
a. **Manual approach**: return to Holistics and click **Publish** to Production.
b. **Auto-publish**: or you can leverage our **[Publish API](/docs/continuous-integration/auto-publish)** to auto Publish your changes whenever your PR is merged to master (via your GitHub)
Here is a video demonstrating the whole process (using Manual Approach):
You can learn more about this process via the official docs of the Git providers:
- [GitHub](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule)
- [GitLab](https://docs.gitlab.com/ee/user/project/protected_branches.html)
---
## Connect with GitHub App
## Introduction
The **Holistics GitHub App** is the recommended way to connect a GitHub repository to Holistics. You install it once at the organization (or personal account) level, pick the repos you want Holistics to access, and you're done. Both code sync and the [Pull Request Workflow](/docs/continuous-integration/pr-workflow-auto-deploy) are enabled in the same setup.
Compared to the [SSH Deploy Key method](/docs/git-version-control/connect-with-ssh-deploy-key), the GitHub App is:
- **Org-level**, so the connection isn't tied to anyone's personal account. It keeps working when a teammate leaves.
- **No SSH required**. The app uses HTTPS, which is useful for organizations that block SSH.
- **Single setup**. One flow covers both code sync and PR Workflow, instead of a Deploy Key plus Personal Access Token combo.
:::info GitHub only
This guide is for GitHub repositories. For GitLab, Bitbucket, or any self-hosted SSH-compatible Git, use the [SSH Deploy Key method](/docs/git-version-control/connect-with-ssh-deploy-key) instead.
:::
## Who can install the Holistics GitHub App?
Whether you can install the app depends on your role on GitHub:
- **Personal account repos**: you can always connect directly.
- **GitHub org owners**: you can connect to any repo in the org.
- **Repo admins**: you can connect to repos you administer, as long as your org owner has enabled the setting *"Allow repository admins to install GitHub Apps"*. See GitHub's docs on [limiting app installations](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/limiting-oauth-app-and-github-app-access-requests-and-installations).
- **Other org members**: you can't install the app directly. Ask your GitHub org owner to connect from Holistics, or use the [SSH Deploy Key method](/docs/git-version-control/connect-with-ssh-deploy-key) instead.
:::tip Not sure about your role?
If you can access your repo's **Settings** tab on GitHub, you're at least a repo admin.
:::
## Prerequisites
Before you start, make sure:
- You have a GitHub repository that is either **empty** or **was previously connected to Holistics**. If you need a fresh one, [create an empty repo on GitHub](https://github.com/new). Don't initialize it with a README, `.gitignore`, or license.
- You meet the install requirements for your role above.
## How to connect
1. In Holistics, go to **Project Settings → Version Control Settings** and click **Connect to Git repository**.
2. In the dialog, select **GitHub**, choose **GitHub App (Recommended)**, and click **Next**.
3. Click **Install on GitHub**. A popup window opens to GitHub.
:::caution Allow popups from Holistics
Your browser must allow popups from Holistics for this step to work. If nothing happens when you click the button, check your browser's address bar for a blocked-popup icon and allow popups for the Holistics site, then try again.
:::
4. On GitHub, complete the install:
- Pick the organization or personal account you want to install the app on.
- Choose **All repositories** or **Only select repositories** (and pick the ones you want).
- Click **Install**.
If the Holistics app is already installed on your org, GitHub will show an **Update repository access** page instead of the install page. Use it to add the target repo (or remove ones you no longer want), then click **Save**.
5. The popup closes and you return to Holistics. Select your repository from the dropdown.
6. Click **Connect repository**. Holistics validates the repo (it must be empty or previously connected) and completes the setup.
Once connected, your Version Control Settings page shows the connected repository, and you can toggle **Enable PR Workflow** to start using pull requests. No separate token needed.
## Common issues
### My repository isn't in the dropdown
This happens when the app was installed with **Only select repositories** and your target wasn't one of them.
You have two options:
- Ask your GitHub org admin to open **GitHub org settings → Installed Apps → Holistics → Repository access**, add the repo, then refresh the dropdown in Holistics.
- Or, at Step 3, click **Install on GitHub** again to open the "Update repository access" page and add the repo yourself (if you have permission).
### "Connection could not be completed" error
You'll see this if your GitHub org requires approval for new app installations. Even if an org owner approves the request on GitHub, the connection can't finish automatically. This is a GitHub platform limitation.
To get unblocked:
- **Retry** and select a repo you administer. Look for repos without the "request" label.
- **Ask your org owner** to connect from Holistics themselves (Project Settings → Version Control Settings). They'll go through the install directly, without a request step.
- **Use [SSH Deploy Key](/docs/git-version-control/connect-with-ssh-deploy-key)** as a fallback.
### I want to install on a different GitHub organization
At Step 3, click **Change organization** to pick a different org or personal account. Your previous installation isn't affected. The app stays installed there until an org admin removes it.
## Managing the connection
:::info Your Holistics work isn't affected
If the app is uninstalled, suspended, or loses access to a repo, your Holistics development workflow keeps working normally. You can still edit code, create branches, and commit. What pauses is the sync to your GitHub repository and the ability to create pull requests, until the connection is restored.
:::
### Disconnect in Holistics vs. uninstall on GitHub
These are two different actions. **Disconnecting** in Holistics removes the integration from your project, but the Holistics GitHub App stays installed on your organization. If you want to fully remove the app, go to **GitHub → Org settings → Installed GitHub Apps → Holistics → Uninstall**.
This distinction matters because an installed app that isn't linked to any Holistics project is harmless, but it can be confusing to an org admin reviewing their installed apps later.
### What happens if the GitHub App is uninstalled?
If someone uninstalls the app on GitHub, Holistics detects it right away and marks the integration as **disconnected**. You'll see an error state in Version Control Settings with options to **Reconnect with GitHub App** or **Switch to SSH Deploy Key**.
If multiple Holistics projects share the same GitHub App installation (same org, different repos), uninstalling affects all of them.
### What happens if the app is suspended?
Suspension is different from uninstall. It's temporary. The integration pauses (code sync and PR Workflow are on hold) and Holistics shows a suspended state. When an org admin unsuspends the app on GitHub, the integration resumes automatically. No action needed from you.
### What happens if repo access is revoked?
If an org admin removes your repo from the Holistics app's allowed list, the integration for that repo disconnects with a specific message: *"Repository access was revoked in GitHub."* Ask the admin to re-add the repo, and Holistics will reconnect automatically.
## Switching from SSH Deploy Key to GitHub App
Already using the [SSH Deploy Key](/docs/git-version-control/connect-with-ssh-deploy-key) method and want to move to GitHub App? You can switch without recreating your repo:
1. In Holistics, unlink your current Deploy Key connection.
2. Follow the steps above to connect via GitHub App, pointing to the same repo.
Branches and commit history are preserved. Only the authentication method changes. Your existing Personal Access Token (if you set one up for PR Workflow) is no longer needed and can be revoked on GitHub.
## Security
The GitHub App method is designed around org-level control and least-privilege access:
- **Org-level install**, managed from GitHub org settings. Your IT/Security admin can see exactly what's installed, which repos it can access, and revoke it in one click.
- **Scoped permissions**. The app only requests `contents: read/write`, `pull requests: read/write`, and `metadata: read`. No org-level permissions, no administration access.
- **Short-lived tokens**. Holistics doesn't store a long-lived token. Instead, it requests a fresh 1-hour installation token from GitHub each time it needs to perform a Git operation.
- **No personal account dependency**. The integration belongs to the organization, so team changes don't break anything.
## Frequently asked questions
### What permissions does the app request, and why?
The Holistics GitHub App only requests **repository permissions**. It does not request any organization or account permissions, so it has no visibility into your org membership, billing, or personal account data.
Within repository permissions, the app requests:
- `contents: read/write`: to clone your repo and push commits.
- `pull requests: read/write`: to create PRs, track their status, and trigger auto-publish on merge.
- `metadata: read`: required by GitHub for all apps (basic repo info).
We don't request `administration` or any other repository permission beyond what's listed above.
### Can the app create a new GitHub repository for me?
No. Creating repos would require GitHub's `administration: write` permission, which also allows repo deletion, branch protection changes, and more. That's a broader footprint than we want for a data integration. You'll need to create the empty repo on GitHub first, then connect.
---
## Connect with SSH Deploy Key
## Introduction
The **SSH Deploy Key** method connects Holistics to your Git repository using an SSH key pair. It works with any Git provider that supports SSH, including GitLab, Bitbucket, self-hosted GitLab or Gitea, and GitHub.
Use this method when:
- You're on **GitLab, Bitbucket, or any other SSH-compatible provider**. This is the only supported method for non-GitHub providers.
- You're on **GitHub** but can't install the [GitHub App](/docs/git-version-control/connect-with-github-app). For example, your org blocks third-party app installs.
:::tip On GitHub? Use GitHub App instead
If you're connecting a GitHub repo, the [GitHub App method](/docs/git-version-control/connect-with-github-app) is the recommended path. It's org-level, doesn't require SSH, and sets up PR Workflow in the same flow.
:::
## Prerequisites
Before you start, make sure:
- You have a Git repository that is either **empty** or **was previously connected to Holistics**. Don't initialize it with a README, `.gitignore`, or license.
- Your Git provider allows SSH connections. Some organizations restrict SSH at the org level. If yours does, you'll need to ask an admin to enable it, or switch to the [GitHub App method](/docs/git-version-control/connect-with-github-app) if you're on GitHub.
## How to connect {#how-to-connect}
1. Create an **empty Git repository** on your provider of choice.
Regardless of which provider you use, make sure **not to initialize the repository with any default files** (README, LICENSE, etc.). Below is an example for GitHub:
2. In Holistics, navigate to the **Version Control Settings** page.
3. In the **Connect Git Provider** dialog, pick your provider: **GitHub**, **GitLab**, or **Other** (for Bitbucket and any other SSH-compatible provider). For GitHub, also select **SSH Deploy Key** as the connection method. Enter your repository's **SSH URL** and click **Continue**.
4. Holistics generates a **Deploy Key** for your repository. Copy it and add it to your repository's settings:
Make sure to enable **Allow write access**. Holistics needs this to sync changes back to your repo.
:::info
The Deploy Key is a public key. Even if exposed, it can't be used to access your repository on its own. Learn more in the [security section](#security) below.
:::
5. Navigate back to the **Version Control Settings** page and click **Test connection**.
6. Once the connection is established, Holistics initializes your repository with a `master` branch and a set of folders, one for each object type that's synced.
## Using SSH Deploy Key with PR Workflow on GitHub
If you're connecting a GitHub repo with Deploy Key and want the [Pull Request Workflow](/docs/continuous-integration/pr-workflow-auto-deploy), you'll also need a **GitHub Personal Access Token (PAT)**. The Deploy Key handles code sync, but PRs need GitHub API access, which only the PAT (or a GitHub App) can provide.
Follow the [GitHub PR Workflow setup guide](/docs/continuous-integration/github-pr-workflow) to configure the PAT.
If you're on GitLab, use a Project Access Token instead. See the [GitLab MR Workflow setup guide](/docs/continuous-integration/gitlab-mr-workflow).
## Security {#security}
Short answer: Holistics only stores the **private key** on our servers, your Git provider only ever receives the **public key**, which is harmless on its own.
In more detail, when you connect, Holistics generates an SSH key pair:
- **Public key**: This is what you paste into your Git provider's settings. Even if exposed, it grants no repository access without the corresponding private key.
- **Private key**: Stays on Holistics servers and is never exposed. It's protected with multiple layers:
- Encrypted with a passphrase
- The passphrase itself is also encrypted
- Stored in secure infrastructure with strict access controls
Additional security properties:
- The Deploy Key is scoped to a **single repository**, with no broader account access.
- You control write permissions via the **"Allow write access"** toggle in your Git provider.
- You can revoke access instantly by removing the key from your repository settings.
- No OAuth tokens or app credentials are stored.
## Switching to GitHub App (GitHub users)
If you're on GitHub and want to move to the more secure, org-level [GitHub App method](/docs/git-version-control/connect-with-github-app), you can switch without recreating your repo:
1. In Holistics, unlink your current Deploy Key connection.
2. Reconnect via GitHub App, pointing to the same repo.
Branches and commit history are preserved. Only the authentication method changes. You can also revoke the old Deploy Key on GitHub once you're done.
---
## Connect to GitHub, GitLab, Bitbucket, and more
:::info Note
External Git is available to all customers in **Standard Plan and above**.
:::
## Introduction
Holistics stores your analytics code in an internal git repository by default. Connecting it to your own external repository lets you use your team's existing git tools and workflows on GitHub, GitLab, Bitbucket, or any SSH-compatible provider.
Benefits of connecting an external repository:
- **Enforce code review before changes go live.** Require [pull request approvals](/docs/continuous-integration/pr-workflow-auto-deploy) so no change reaches production without a review.
- **Integrate with your CI/CD pipeline.** Auto-publish to Holistics production whenever a PR is merged to `master`.
- **Work in your local editor.** Use VS Code or any editor to write and manage your analytics code, then push changes directly.
- **Keep your code in your own account.** Your team gets a full copy of the analytics codebase, visible in your company's Git history.
## How it works
When you connect an external repository, Holistics mirrors your analytics code there.
* Any change made in Holistics, on any branch, is pushed to the corresponding branch in your external repo.
* The `master` branch is treated as production: pushing or merging changes into `master` externally triggers an auto-publish to Holistics production, but only if you've set up the [Pull Request workflow](/docs/continuous-integration/pr-workflow-auto-deploy) or [Publish API](/docs/continuous-integration/auto-publish).
## How to connect
Holistics supports two connection methods. Pick the one that fits your setup:
### GitHub App (recommended for GitHub users)
Connect at the **organization level**. One setup enables both code sync and PR workflow, with no SSH keys or personal access tokens to manage. The integration belongs to the organization, so team changes don't break anything.
→ [Connect with GitHub App](/docs/git-version-control/connect-with-github-app)
### SSH Deploy Key
Connect using an SSH key pair scoped to a single repository. Required for **GitLab, Bitbucket, and other SSH-compatible providers**, and available as a fallback for GitHub users who can't install apps on their organization.
→ [Connect with SSH Deploy Key](/docs/git-version-control/connect-with-ssh-deploy-key)
:::info Repository must be empty
Whichever method you choose, your external repository must be either **completely empty** (no commits, no README, no LICENSE) or **previously connected to Holistics**. Otherwise the git histories won't match and the connection will fail.
:::
## After connecting
Here's how the sync works once you're connected:
- **Holistics → external repo:** Any commit made in Holistics is automatically pushed to the corresponding branch in your external repository.
- **External repo → Holistics production:** Changes merged into `master` in your external repo are auto-published to Holistics production, as long as:
- You've set up auto-publish via [**Pull request workflow**](/docs/continuous-integration/pr-workflow-auto-deploy) (recommended) or the [**Publish API**](/docs/continuous-integration/auto-publish)
- Your changes have no validation errors
To get the most out of this setup, consider enforcing a [**pull request & code review process**](/docs/continuous-integration/pr-workflow-auto-deploy) on your external repository.
:::danger
Do not rewrite or modify commit history (e.g. force push) in your external repository. Holistics' sync depends on linear commit history, and modifying it can cause unexpected behavior.
:::
## Frequently Asked Questions
### Which connection method should I use?
- **[GitHub App](/docs/git-version-control/connect-with-github-app)** if you're on GitHub and can install apps on your organization (or you're connecting a personal repo). It's the simplest, most secure setup.
- **[SSH Deploy Key](/docs/git-version-control/connect-with-ssh-deploy-key)** for GitLab, Bitbucket, self-hosted Git, or when GitHub App install is blocked by your org.
### What happens if I unlink my connection to an external repository?
No worries, unlinking won't delete your changes or break anything. Your projects and dashboards keep working normally because everything is stored in Holistics' internal repository. **You can unlink anytime without any impact**.
Note that unlinking in Holistics does *not* uninstall the GitHub App (if you used that method) or remove the Deploy Key from your provider. If you want to fully remove access, do that from your Git provider's settings separately.
### Can I connect Holistics to an existing repository?
Yes, but only if the repository is either:
- Completely empty, or
- Was previously connected to Holistics (i.e., you unlinked it and want to reconnect)
Otherwise, the Git histories won't match and the connection will fail.
### Can I switch between connection methods later?
Yes. You can switch between GitHub App and SSH Deploy Key (or vice versa) without recreating your repo. Unlink the current connection, then connect again using the other method. Your branches and commit history are preserved.
See [Switching from SSH Deploy Key to GitHub App](/docs/git-version-control/connect-with-github-app#switching-from-ssh-deploy-key-to-github-app) for details.
### How can I migrate my repository (e.g., from GitHub to GitLab or Bitbucket)?
Here's how to switch providers:
1. Import your repository to the new platform (e.g., [GitHub to GitLab](https://docs.gitlab.com/ee/user/project/import/github.html), [GitHub to Bitbucket](https://support.atlassian.com/bitbucket-cloud/docs/import-a-repository-from-github-or-gitlab/))
2. In Holistics, unlink your current repository
3. Connect to the newly imported repository
### Does Holistics support Bitbucket or other Git providers?
Yes, **Bitbucket** and any Git provider that supports SSH connections work via the [SSH Deploy Key method](/docs/git-version-control/connect-with-ssh-deploy-key). In the Connect Git Provider dialog, select **Other** and enter your repository's SSH URL.
For custom Git domains (e.g. a self-hosted GitLab or Gitea instance), we'll need to verify them first for security. Just [submit a verification request](https://form.jotform.com/201562151159045?ticketType[2]=true&priority14[1]=true&subject=Git%20Domain%20Verification%20Request) and our team will review and approve it.
---
## File History & Restore
:::info Coming soon
Per-file history and restore is on the way. This page previews what it will do; we'll update it when the feature ships.
:::
## Overview
Every file in your project - dashboards, models, datasets - will get its own **File History**: a timeline of every version of that file, showing who changed it, when, and why. From that history you'll be able to restore a single file to any past version without touching anyone else's work.
Today, restoring a past version works at the project level: rolling back reverts the entire project, including teammates' unrelated changes since then. File history makes rollback precise - one file, nothing else.
## Key capabilities
- **Trace a file's history** - see every version with author, timestamp, and commit message, plus what each version changed.
- **Restore a single file** - roll one file back to a past version. Only that file changes.
- **Duplicate to a new file** - fork a past version into a new file to preview or compare, leaving the current file untouched.
- **Jump to project history** - if a change spanned several files, open the full project history at that point to see everything that moved together.
## How it works
Open a file's **History** panel to see its version timeline. Each entry shows who made the change, when, and why, and you can open any version to see exactly what it changed.
**When a version is captured** depends on your project's version-control mode:
- On [Git flow](/docs/git-version-control) (explicit commits), a new version is recorded each time you commit.
- On non-git flow (auto-commit), every change you make is captured automatically as you work - no manual commit needed.
**Restoring affects only the file you're viewing.** A single commit often touches several files at once. When you restore from one file's history, Holistics rolls back only that file's changes from the version you picked - every other file stays exactly as it is.
**What happens when you restore** also depends on your mode:
- On [Git flow](/docs/git-version-control), the restored file appears as an uncommitted change, so you can review it one last time before committing it yourself.
- On non-git flow, the restore is committed automatically, like any other save.
You can also **duplicate a past version to a new file** to compare side by side without overwriting your current work. And because history is preserved, a restore is itself just another change - if it wasn't what you wanted, restore again.
## Related
- [Restore Previous Versions](/docs/git-version-control/version-restore) - roll back the whole project to a past version.
---
## Version Control with Git
:::tip Familiarity with Git
This section assumes that you are familiar with Git and Version Control concepts. In case you need a refresher, here are some excellent materials: [Git's official docs](https://git-scm.com/book/en/v2/Getting-Started-About-Version-Control), [GitHub - About Git](https://docs.github.com/en/get-started/using-git/about-git).
:::
## Introduction
In Holistics, all of your analytics code is **version-controlled with Git**. This means you can enjoy the benefits of Git within the Holistics development environment, such as:
- **Isolated environment:** Multiple data builders can work on different code branches without blocking each other
- **Audit logging:** Keeping track of the changes made to your analytics code base, knowing who modified what
- **Applying code review procedure** to double-check, approve, and disapprove changes. Deploy only when you are confident with your changes
- **"Time-traveling":** Reverting to a previous version in case something bad happens
The basic features of Git are made accessible via the friendly graphical interface of **Development workspace**. On the other hand, advanced users still have access to all underlying Git features by using the command line.

## Enable Git workflow
:::info Note
By default, Holistics will use the **Holistics Repository** to keep track of code changes.
In Standard Plan and above, you can also [connect to an **external repository**](/docs/git-version-control/external-git) that you manage yourself.
:::
Simply go to **Development** workspace > **Settings** tab > click "**Enable Git Workflow**".
## High-level workflow
A typical workflow in Development workspace involves the following operations:
1. **Enable Development Mode:** In **Development workspace** page, the **Production** mode is a "read-only" mode where you will see codes in the `master` branch which was already deployed to production. To start making changes, simply toggle to **Development mode**.
2. [**Prepare your branch**](/docs/git-version-control/branch-management):
- **Work on an existing branch:** In case you return to work on an existing branch, and the branch's code is already out-dated compared to production, you will need to click **Pull from production** to update your branch.
- **Create a new branch:** You can also create a new branch out of the `master` branch to have access to the most up-to-date code, or create a new branch out of a development branch to further test your ideas.
3. **Make changes & record changes**: After making changes in your code base, you need to **Commit and Push** to record the changes to your code base. You can easily **abort the changes** that you have not committed, or even [**restore**](/docs/git-version-control/version-restore) to a previously committed version.
4. [**Validate Reports**](/docs/development/reporting-validation): Before publishing your new changes to Production, it is advisable to use the **Report Validation** feature to check if the changes have broken any downstream reports.
5. [**Publish**](/docs/development/dev-prod-mode): After you have [**validated**](/docs/development/reporting-validation) your code and committed all changes, click **Publish** to deploy your changes to Production, and so available to end-users.
In reality, the development workflow may not be so straightforward. Here are some other operations that you may need to know about:
- [**Restore a version**](/docs/git-version-control/version-restore)
- [**Resolve merge conflicts**](/docs/git-version-control/resolve-merge-conflicts)
## Mechanism
The version control feature of Holistics is powered by **Git**. Behind the scenes, Holistics tracks analytics code changes with one of the following:
- **Holistics (System) Repository**: A Git repository that Holistics hosts and maintains internally on the server.
- **External Repository**: Repository that sits on customers’ servers or Git providers (like GitHub, GitLab, or Bitbucket)
The Holistics Repository is in use by default. However, once you have [connected to your own external Git repository](/docs/git-version-control/external-git), Holistics will only use the external one.

Without External Git repository
With External Git repository
---
## Resolve Merge Conflicts
## Introduction
:::tip You should know
A **merge conflict** arises when two Analysts make changes in the same line in a file, or one Analyst deletes a file but another Analyst makes edits in the same file.
:::
Merge conflicts are bound to happen when you pull changes from other branches. Should this happen, Holistics will use the standard Git syntax [conflict markers](https://git-scm.com/docs/gitattributes#Documentation/gitattributes.txt-text) to display conflicts during merging.
The merge cannot be executed automatically because **Git** cannot determine which changes to keep, so you will need to resolve the conflicts manually.
## How to resolve merge conflicts
VIDEO
1. **Navigate to the conflicted files**:
- Click on the "Show Conflicts" button to see the list of conflicted files
- You can jump to each file from this list
2. **Quickly choose which version you want to keep for a file**
- Upon arriving at a conflicted file, you will open the **Quick resolve** option by default
- From here, you can choose to keep your version or the incoming version for this file
- Click Resolve, and Confirm to finish resolving the file
3. **Manually resolve the conflict for a file**
- Aside from the **Quick resolve** option, you can always switch to the **Manual resolve** option to work in the code editor
- Click Resolve, and Confirm to finish resolving the file
4. **Quickly choose which version you want to keep for all files**
- From the "Show Conflicts" button, click on the "Resolve all" button
- Then, choose to apply either your version, or the incoming version for all files.
- Confirm to finish
---
## Restore Previous Versions
## Introduction
We've all been there: you make some changes to your project, publish, and then realize something broke. Maybe a model stopped working, or you accidentally deleted an important relationship. Version Restore gives you the confidence to experiment and make changes, knowing you can always go back to a working state.
:::info Restoring a single file?
Version Restore rolls back your **entire project**. To roll back just one dashboard or model without affecting other files, see [File History & Restore](/docs/git-version-control/file-history-restore).
:::
### Why Version Restore Matters
Version Restore acts as your project's safety net. Whether you're refactoring models, updating relationships, or testing new transformations, mistakes happen. Instead of manually undoing changes or trying to remember what you changed, Version Restore lets you jump back to any previous version with a single click.
### Key Benefits
- **Non-destructive rollback**: Restoring doesn't delete your version history. It creates a new version with the old code, so nothing is lost
- **Fully reversible**: Changed your mind? You can restore again to get back to where you were
- **Zero downtime**: Quickly recover from mistakes without disrupting your team's work
### How It Works
Think of Version Restore as a time machine for your project. Here's the high-level flow:
1. **Browse your history**: View all previous versions of your project in the Source Control tab, with commit messages showing what changed
2. **Pick a restore point**: Find the version before things went wrong, usually the immediate previous version
3. **Restore safely**: Click the restore button, and Holistics copies that version's code forward as a new version
4. **Continue working**: Your Development workspace updates with the restored code, and you can keep working normally
The key thing to understand: Version Restore doesn't "rewind" your history like an undo button. Instead, it takes a snapshot of an older version and applies it as a brand new version on top of your current code. This means your entire version history stays intact, and you can always restore forward again if needed.
## How to restore to the previous version
### Step 1: Go to Source Control
Go to “Source control” tab on the left toolbar to view previous versions of your project.
### Step 2: Identify the version that you want to revert back to
If you have identified the version in which you implemented some undesirable changes, most likely you will want to revert to the **immediate previous version.**
:::tip Commit frequently with a clear message
- It is beneficial to commit frequently so that when you have to revert, you do not need to re-implement too much.
- Clear commit messages and descriptions will help you identify versions more easily.
:::
### Step 3: Initiate version restore
To initiate the version restore process, click on the clock symbol next to the version you want to revert back to.
You can initiate a version restore when viewing a particular version, or when viewing the entire version history.
### Step 4: View restored files in Development workspace
After a version restore is completed, your current version now contains the code of the previous "clean" version.
You can go to the latest version and jump to relevant files in Development workspace using the link on each file.
---
## Google Spreadsheets Setup
## What is Google Sheet Data
The **Google Spreadsheet Data Model** lets you load data from your Google Spreadsheets to your relational databases.
## Model your Google Spreadsheet data
::: warning Requirement
Please note that you need a Data Warehouse to load your Google Spreadsheets data into. If you don't have any, please create one or contact us via [support@holistics.io](mailto:support@holistics.io).
:::
### 1. Go to Data Modeling page
At this step, you must already have a modeling-support source. Select the source that you want to load your Google Spreadsheets into.
### 2. Create Data Model from Other Sources
Click on Create, choose Other Sources and select **Google Spreadsheets**.
### 3. Connect to Google Spreadsheet
Paste the URL of your Google Spreadsheet into the Google Source URL box, then click Validate to preview your data.
If this is the first time you connect to a spreadsheet, you will be prompted to grant Holistics permission to connect to your Google Account. Please make sure that Holistics's popup is allowed in your browser for the authentication to work.
### 4. Advanced Settings
From [Advanced Settings](import-models#advanced-settings) you can modify the destination table from Destination Settings, and control how column types will be cast from Sync Configuration. Please visit the dedicated page for more details.
## Other Notes
### Google Spreadsheet's Sync Configuration
By default, all the columns in your Google Spreadsheet will be cast into TEXT/STRING type when loading to your databases. Please make sure to select the desired data types in the Sync Configuration section before starting loading your data.
---
## Holistics best practices
Holistics works best when your team treats it as a governed analytics product, not just a place to build charts. This guide brings the most important habits into one page, with links to the detailed docs when you need setup steps or feature-specific examples.
Use this as a review checklist when you start a new Holistics project, clean up an existing workspace, or prepare a dashboard and dataset for broader rollout.
## At a glance
The table below is the shortest version of this guide. Start with the area that is causing friction for your team, then follow the linked docs for the details.
| Area | Best practice | Read more |
| --- | --- | --- |
| Semantic layer | Keep business logic in reusable models, relationships, fields, and metrics instead of recreating it in each report. | [Design models](/best-practices/modeling/design-models), [Naming conventions](/best-practices/modeling/meaning-name) |
| Project structure | Organize AML files so analysts can find the right model, dataset, or dashboard without tribal knowledge. | [Organize AML project](/best-practices/modeling/organize-project), [Project structure](/docs/development/project) |
| Datasets | Design datasets around user questions, with a balance between focused self-service and flexible exploration. | [Dataset best practices](/docs/datasets/best-practices) |
| Dashboards | Build dashboards around the decision users need to make, then choose layout, charts, and formatting to support that decision. | [Dashboard best practices](/best-practices/dashboard), [Canvas dashboards](/docs/dashboards) |
| Performance | Reduce unnecessary work before tuning the warehouse: fewer widgets, smaller result sets, pre-aggregates, persistence, and clear join paths. | [Troubleshoot slow reports](/docs/report-performance), [Aggregate awareness](/docs/aggregate-awareness) |
| Permissions | Combine roles, object sharing, row-level permission, column-level permission, and database controls deliberately. | [Permission system](/docs/admin/permission-system), [Row-level permission](/docs/access-control/row-level-permission) |
| Release workflow | Use Development Mode, Git, review, validation, and controlled publishing before changes reach end users. | [Development and production modes](/docs/development/dev-prod-mode), [Pull request workflow](/docs/continuous-integration/pr-workflow-auto-deploy) |
| Discovery | Use tags, endorsement, archiving, and ownership conventions so users can find trusted content. | [Tagging best practices](/docs/tags/tips), [Content endorsement](/docs/find-organize/content-endorsement) |
| AI readiness | Give Holistics AI strong semantic context, clear descriptions, and focused AI Skills. | [What AI uses for context](/docs/ai/context/overview), [AI Skills](/docs/ai/skills) |
## Build from a governed semantic layer
Your semantic layer should be the source of truth for business definitions. If two dashboards calculate the same metric differently, users will eventually stop trusting both.
Start by keeping reusable logic in models, fields, relationships, and metrics. Use custom metrics when the logic is genuinely reusable or too complex for the exploration interface, but avoid turning every one-off report calculation into a permanent semantic object.
Good semantic-layer hygiene usually means:
- Use clear names for model files and fields, then use business-friendly labels for what end users see.
- Add descriptions to important models, fields, datasets, and metrics so analysts and AI have enough context.
- Declare reusable relationships where the same join logic is used across datasets.
- Hide technical fields, join keys, helper dimensions, and duplicated raw fields from exploration.
- Keep business definitions close to the semantic layer instead of scattering them across dashboard-specific formulas.
Related docs:
- [Tips on designing models](/best-practices/modeling/design-models)
- [Model and field naming convention](/best-practices/modeling/meaning-name)
- [Query models](/docs/query-models)
- [AQL best practices](/as-code/aql/best-practices)
## Design datasets around user questions
Datasets are the interface most users explore from. A good dataset should make the right fields obvious, protect users from confusing combinations, and still leave enough flexibility for follow-up questions.
Avoid designing every dataset as a giant catalog of everything your warehouse contains. Smaller datasets are easier for business users to trust, while larger exploratory datasets are useful for analysts who understand the data shape and tradeoffs.
When designing datasets:
- Start from the questions a team needs to answer, not from the tables available in the warehouse.
- Build focused datasets for common self-service use cases.
- Keep broader datasets for advanced exploration, and make their naming and descriptions especially clear.
- Prefer star-schema-like paths where important dimensions and measures are close together.
- Avoid exposing large raw event or log tables directly to end-user exploration.
- Watch for fan-out risks when relationship cardinality does not match the real data.
- Use row-level and column-level permission rules on the dataset when different users should see different data.
Related docs:
- [Dataset best practices](/docs/datasets/best-practices)
- [Dataset relationships](/docs/datasets/dataset-relationships)
- [Row-level permission](/docs/access-control/row-level-permission)
- [Column-level permission](/docs/access-control/column-level-permission)
## Build dashboards around decisions
A dashboard should help someone understand what changed, why it changed, and what to do next. That is a different goal from showing every available metric on one page.
Before building, decide who the dashboard is for and what decision or workflow it supports. Then use layout, filters, chart choice, and drill paths to make that workflow easy.
Strong dashboards tend to follow these patterns:
- Put the most important metrics and trends near the top.
- Separate overview dashboards from detail dashboards instead of forcing one page to do both jobs.
- Use drill-through and cross-filtering when users need to move from summary to investigation.
- Keep filters visible and understandable, especially date filters and team or region filters.
- Use number formats that match the scale of the metric, such as `5.2M` instead of `5,200,000`.
- Keep chart types boring when the question is simple. A clear bar, line, table, or metric sheet is often better than a clever chart.
- Add text blocks only when they explain context, caveats, owners, or links that help users act on the dashboard.
Related docs:
- [Dashboard best practices](/best-practices/dashboard)
- [Interact with Canvas dashboards](/docs/interactions/interact-with-canvas-dashboard)
- [Drill-through](/docs/interactions/drill-through)
- [Cross-filtering](/docs/cross-filtering)
## Keep reports fast by design
Performance is easier to protect during design than to rescue after rollout. Slow dashboards usually come from a combination of too much data, too many widgets, complex joins, expensive warehouse queries, or large result transfers.
Start with the simplest fixes: ask for less data, reduce unnecessary widgets, and make sure users are not exploring raw tables when a modeled or aggregated table would answer the question better.
Use this order when reviewing performance:
1. Check whether the dashboard has too many widgets or returns too many rows.
2. Check whether filters should default to a narrower date range or business area.
3. Check whether the dataset join path is longer or more complex than needed.
4. Pre-aggregate large datasets where users usually ask at a higher grain.
5. Use Aggregate Awareness for smoother exploration across different grains.
6. Persist expensive Query Models when recomputing them each time is not practical.
7. Use Job Monitoring and Performance Monitoring to identify repeated slow queries.
8. Tune the warehouse only after you know which generated SQL is causing the delay.
Related docs:
- [Troubleshoot slow reports](/docs/report-performance)
- [Performance monitoring](/docs/monitoring/performance-monitoring)
- [Job monitoring](/docs/monitoring/job-monitoring)
- [Aggregate awareness](/docs/aggregate-awareness)
## Govern access intentionally
Permissions are not one setting in Holistics. They work in layers, so your setup should be explicit about what each layer is responsible for.
Use roles for feature access, workspace and object sharing for content access, data source permission for analyst access to source systems, and data-level permissions for row and column restrictions inside datasets.
As a rule of thumb:
- Give users the lowest role that supports their real workflow.
- Use the Public workspace for shared, production-ready content.
- Use Personal workspaces for drafts, experimentation, and user-owned analysis.
- Use object-level sharing to control who can see a dashboard, folder, or dataset.
- Use row-level permission when the same dataset should return different rows by user, team, region, account, or tenant.
- Use column-level permission when sensitive fields should be hidden from some users.
- Use database passthrough authentication when warehouse-level identity and policies should remain the enforcement point.
- Test permission behavior with a real user context before rolling it out broadly.
Related docs:
- [Permission system](/docs/admin/permission-system)
- [User roles](/docs/admin/user-roles)
- [Row-level permission](/docs/access-control/row-level-permission)
- [Database passthrough authentication](/docs/access-control/database-passthrough-authentication)
## Release changes with review
Holistics projects become production systems once other people depend on them. Treat changes to models, datasets, permissions, and shared dashboards with the same care you would give application code.
At minimum, separate development from production, commit meaningful changes, and review the impact before publishing.
A healthy release workflow includes:
- Make changes in Development Mode, not directly against production.
- Use Git branches when multiple people contribute to the same project.
- Require pull requests for changes that affect shared datasets, core metrics, permissions, or executive dashboards.
- Include screenshots or result samples when a dashboard changes.
- Validate impacted reporting items before publishing modeling changes.
- Use auto-publish only after the team has a reliable review and merge process.
- Keep rollback paths clear by using Git history and file history restore.
Related docs:
- [Development Mode, Production Mode and deployment](/docs/development/dev-prod-mode)
- [Version control with Git](/docs/git-version-control)
- [Pull request workflow](/docs/continuous-integration/pr-workflow-auto-deploy)
- [Reporting validation](/docs/development/reporting-validation)
## Make trusted content easy to find
As usage grows, the problem shifts from "Can we build this?" to "Which version should I trust?" Tags, endorsement, archiving, and ownership conventions help users find the right thing without asking the data team every time.
Do not wait until the workspace is messy before defining lifecycle rules. A small, consistent system is better than a perfect taxonomy that nobody maintains.
Start with:
- Topic tags for business areas, such as `topic/sales` or `topic/finance`.
- Status tags for lifecycle, such as `status/draft`, `status/review`, or `status/active`.
- Owner tags for accountability, such as `owner/data-team` or `owner/finance`.
- Issue tags for temporary warnings, such as `issue/stale-data` or `issue/under-maintenance`.
- Endorsement for dashboards, datasets, and reports that are safe for broad use.
- Archiving for outdated content instead of leaving old versions searchable.
- A regular review cadence for important shared dashboards and datasets.
Related docs:
- [Tagging best practices](/docs/tags/tips)
- [Tags](/docs/find-organize/tags)
- [Content endorsement](/docs/find-organize/content-endorsement)
- [Archive content](/docs/find-organize/archive)
## Prepare your workspace for AI
Holistics AI is only as useful as the semantic and business context it can rely on. Strong model descriptions, clear field labels, governed metrics, and focused AI Skills make AI answers more consistent.
Think of AI readiness as an extension of analytics governance. If a human analyst would need more context to answer a question correctly, AI probably needs that context too.
For better AI results:
- Add descriptions to important datasets, models, fields, and metrics.
- Keep canonical business definitions in the semantic layer.
- Use AI Skills for repeatable analysis workflows, team-specific terminology, and output conventions.
- Keep each AI Skill focused on one job.
- Include the phrases users actually say in each skill description.
- Reference existing metrics and dashboards instead of redefining logic inside the skill.
- Test AI Skills with real user questions before rolling them out.
- Keep permissions accurate, since AI respects the user's existing access.
Related docs:
- [What AI uses for context](/docs/ai/context/overview)
- [Semantic and reporting layers](/docs/ai/context/semantic-and-reporting-layers)
- [AI Skills](/docs/ai/skills)
- [AI user access](/docs/ai/user-access)
## Review your setup regularly
Best practices only work if they survive normal team growth. Schedule lightweight reviews so the workspace stays usable as datasets, dashboards, users, and AI workflows expand.
Use this cadence as a starting point:
| Cadence | What to review |
| --- | --- |
| Before publishing | Broken reports, changed metrics, permission impact, dashboard screenshots, and performance risk. |
| Monthly | Slow dashboards, unused or duplicated content, stale tags, failed schedules, and recently changed critical datasets. |
| Quarterly | Core metric definitions, dataset design, row-level permission rules, endorsed content, AI Skills, and ownership tags. |
| When teams change | User roles, group membership, data access rules, dashboard owners, schedule owners, and API access. |
The goal is not process for its own sake. The goal is to keep Holistics trustworthy enough that business users can answer common questions without asking the data team to verify every number.
---
## Arithmetic
Arithmetic combines two fields with an operator to create a new one.
## Add (+)
Adds the values of two fields together.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Base field | Yes | The first numeric field. |
| Plus | Yes | The field to add. |
### Use cases
- Total cost = price + shipping
- Fully loaded salary = base pay + bonus
- Session length = active time + idle time
## Subtract (-)
Subtracts the value of one field from another.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Base field | Yes | The field to subtract from. |
| Minus | Yes | The field to subtract. |
### Use cases
- Profit = revenue − cost
- Net price = list price − discount
- Remaining quota = limit − usage
## Multiply (×)
Multiplies the values of two fields.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Base field | Yes | The first numeric field. |
| Multiplied by | Yes | The field to multiply by. |
### Use cases
- Line total = price × quantity
- Tax amount = taxable total × tax rate
- Weighted score = rating × weight
## Divide (÷)
Divides the value of one field by another.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Base field | Yes | The numerator. |
| Divided by | Yes | The denominator. |
### Use cases
- Conversion rate = orders ÷ visits
- Average order value = revenue ÷ orders
- Attach rate = add-ons ÷ base products
---
## Basic aggregations
Basic aggregations summarize values in a group into a single metric.
## Count
Counts the total number of items in a group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The dimension whose values to count. |
### Use cases
- Number of orders
- Number of support tickets
- Count of line items per invoice
## Count rows
Counts the number of rows in a table.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Table to aggregate | Yes | The table (data model) whose rows to count. |
### Use cases
- Number of records in a model
- Row count of events after filters
- Size of a staging table
## Count distinct
Counts the total number of distinct items in a group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The dimension whose unique values to count. |
### Use cases
- Number of unique customers
- Distinct products sold
- Unique countries with orders
## Average
Calculates the average of values in a group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to average. |
### Use cases
- Average order value
- Mean session duration
- Average items per cart
## Min
Returns the item in the group with the smallest value.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The dimension to find the minimum of. |
### Use cases
- Earliest signup date
- Lowest order amount
- First event timestamp
## Max
Returns the item in the group with the largest value.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The dimension to find the maximum of. |
### Use cases
- Highest order amount
- Latest login time
- Peak daily active users
## Sum
Calculates the sum of values in the group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to add up. |
### Use cases
- Total revenue
- Sum of quantities ordered
- Total discount amount
## Median
Computes the median of the values in the group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to find the median of. |
### Use cases
- Median order value
- Median time to first response
- Median household income in a segment
## Continuous percentile
Returns the value at the given percentile of the sorted expression values, interpolating between adjacent values if needed.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to compute the percentile of. |
| Percentile | Yes | Where in the distribution to read the value:**Presets**: P10, P25, P50, P75, P90, P95, P99.**Custom**: enter a fraction between 0 and 1 (e.g. 0.95 for P95). |
### Use cases
- 95th percentile of response time (P95 preset)
- P75 order value for pricing decisions
- P50 as a continuous median of session duration
## Discrete percentile
Returns the value at the given percentile of the sorted expression values. If the percentile falls between two values, one of them will be returned (the logic to select the value is database dependent).
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to compute the percentile of. |
| Percentile | Yes | Where in the distribution to read the value:**Presets**: P10, P25, P50, P75, P90, P95, P99.**Custom**: enter a fraction between 0 and 1 (e.g. 0.9 for P90). |
### Use cases
- 90th percentile of order value (P90 preset)
- Discrete P50 when you need an actual observed value, not an interpolated one
- P99 query latency without interpolation
## Sample standard deviation
Computes the sample standard deviation of the values in the group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to measure spread for (sample). |
### Use cases
- Variability of order value (sample)
- Spread of delivery times across a sample of shipments
- Volatility of daily signups in a sampled window
## Population standard deviation
Computes the population standard deviation of the values in the group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to measure spread for (population). |
### Use cases
- Variability of order value (population)
- Spread of all scores in an exam
- Volatility across the full set of daily returns
## Sample variance
Returns the sample variance of the values in the group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to compute sample variance for. |
### Use cases
- Sample order-value variance
- Variance of response times in a sample
- Variance of basket sizes in an A/B test sample
## Population variance
Returns the population variance of the values in the group.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The numeric dimension to compute population variance for. |
### Use cases
- Population order-value variance
- Variance of all monthly revenues in the dataset
- Variance of completion times for every attempt
---
## Comparative calculations
Comparative calculations put a metric in context, either against a total (its share of the whole) or against a past period (how it changed).
## Percent of total
Calculates the percentage of a metric relative to the original value of that metric.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Value | Yes | The metric to turn into a share. |
| Total Value | Yes | Which total to divide by:**Grand total**: share of the overall total.**Row total** / **Column total**: share within that row or column (most useful in a pivot).**Custom**: calculate the total across the dimensions you select (for example, across Category → % within each Country). |
### Use cases
- Revenue share by product category
- Gender mix within each signup year
- Region's share of orders within a channel
- Product share of revenue within each country
## Period over period
Calculates the change of a metric between two periods.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Metric | Yes | The metric to compare across periods. |
| Time dimension | Yes | The date/time field to shift along.On the viz where this period over period metric is used, include this time dimension with at least hour granularity. |
| Comparison period | Yes | How far to compare:**Previous** / **Next**: a relative shift (offset + unit, such as 1 month or 1 year).**Custom**: a fixed date range to compare against. |
| Display | Yes | What to show for each row:**Period value**: the metric in the comparison period.**Change (Δ)**: absolute difference (current − comparison).**% change (%Δ)**: relative difference vs the comparison period. |
### Use cases
- This month's orders vs same month last year
- Weekly revenue vs the prior week
- Today's signups vs same weekday last week
- Campaign week GMV vs a fixed baseline week
---
## Date & time
Date and time calculations compare or adjust dates.
## Date difference
Calculates the difference between two dates in a specified time unit.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Start date | Yes | **Field**: an existing date field**Custom**: a specific date from the calendar |
| End date | Yes | **Field**: an existing date field**Custom**: a specific date from the calendar |
| Time unit | Yes | The unit for the difference: Day, Week, Month, Quarter, or Year. |
### Use cases
- Days between order and delivery
- Weeks from signup to first purchase
- Months of tenure since account created
## Date add
Add or subtract intervals to a date.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Original date | Yes | The date field to shift. |
| Date changes | Yes | One or more intervals to add or subtract (amount + unit, e.g. 2 or -2 days). |
### Use cases
- Estimated delivery date
- Trial end date from signup
- Renewal date 12 months after purchase
---
## Available calculations
## GUI calculations
Below are the supported [calculation builders](/docs/calculation-builder) for creating metrics. When none fit, use a [Custom formula](#formulas) with AQL.
## Formulas
When the available calculations aren't enough, use **Custom formula** to write your own expression in [AQL](/as-code/aql). This is the same as switching to Formula mode, and it gives you full control over the field's logic.
See full list: [AQL functions](/reference/aql/functions).
---
## Listing
Listing returns values from a table, useful for showing related items next to an aggregation.
## List
List values from a data model.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| List of | Yes | The table to list values from. |
| Display fields | Yes | Which fields from that table to show. Defaults to **All Fields**. |
### Use cases
- List of products per order
- Tags on each support ticket
- Campaign names linked to a customer
---
## Logical calculations
Logical calculations apply conditions to decide what to aggregate or return.
## Count if
Counts the total number of items in a group with conditions.
### Inputs
Same as [Count](/docs/calculation-builder/available-calculations/basic-aggregations#count), with a [Filter](/docs/calculation-builder#filter) so only matching rows are counted.
### Use cases
- Number of female users above 30
- Count of delivered orders
- Tickets marked as urgent
## Count distinct if
Counts the total number of distinct items in a group with conditions.
### Inputs
Same as [Count distinct](/docs/calculation-builder/available-calculations/basic-aggregations#count-distinct), with a [Filter](/docs/calculation-builder#filter) so only matching rows are included.
### Use cases
- Unique customers from a region
- Distinct products sold on promotion
- Unique users who completed onboarding
## Average if
Calculates the average of values in a group with conditions.
### Inputs
Same as [Average](/docs/calculation-builder/available-calculations/basic-aggregations#average), with a [Filter](/docs/calculation-builder#filter) so only matching rows are included.
### Use cases
- Average order value for repeat buyers
- Average session duration for mobile users
- Mean delivery time for express shipping
## Min if
Returns the item in the group with the smallest value with conditions.
### Inputs
Same as [Min](/docs/calculation-builder/available-calculations/basic-aggregations#min), with a [Filter](/docs/calculation-builder#filter) so only matching rows are included.
### Use cases
- First order date for VIP customers
- Earliest login among active users
- Lowest quote amount that was accepted
## Max if
Returns the item in the group with the largest value with conditions.
### Inputs
Same as [Max](/docs/calculation-builder/available-calculations/basic-aggregations#max), with a [Filter](/docs/calculation-builder#filter) so only matching rows are included.
### Use cases
- Largest order from a campaign
- Latest renewal date for paid plans
- Highest NPS score among promoters
## Sum if
Calculates the sum of values in the group with conditions.
### Inputs
Same as [Sum](/docs/calculation-builder/available-calculations/basic-aggregations#sum), with a [Filter](/docs/calculation-builder#filter) so only matching rows are included.
### Use cases
- Revenue from a specific product line
- Total discount amount on cleared carts
- Sum of refunds issued this month
## Case when
Returns the value associated with the first condition that evaluates to true.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Case | Yes | One or more cases. Conditions that must match. |
| Then | Yes | Value for each case when it matches. |
| Default value | No | Fallback when no case matches. |
### Use cases
- Bucket customers into spend tiers
- Map status codes to friendly labels
- Flag high-risk orders based on amount and country
---
## Text
Text calculations join strings into one value.
## Concat
Returns the concatenated string of multiple strings.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Text elements | Yes | **Field**: a measure or metric**(Plain) Text****Separator**: space, hyphen, underscore, comma, or plus |
### Use cases
- Full name from first and last name
- City and country as a single location label
- Order ID prefixed with a fixed code
---
## Window calculations
Window calculations rank, accumulate, or smooth values across related rows.
## Rank
Assigns a rank to each record based on the order of specified fields, optionally within partitions.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Ranking mode | Yes | How ties are numbered:**Rank**: ties share a rank; the next rank skips (e.g. 1, 1, 3, 4, 4, 6, ...).**Dense Rank**: ties share a rank; the next rank is consecutive (e.g. 1, 1, 2, 3, 3, 4, ...). |
| Ranking by | Yes | One or more fields to sort by, each ascending or descending. |
| Ranking within | No | Restart the ranking within each group (partition). |
### Use cases
- Rank products by total sales
- Top customers by revenue within each region
- Leaderboard of support agents by tickets closed
## Running total
Calculates a running sum, average, min, or max of a metric along a date dimension on the viz.
**Note:** To run along a non-date dimension, switch to Formula mode and use [`running_total`](/reference/aql/running_total).
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Field | Yes | The metric (or dimension) to accumulate along the date dimension on the viz. |
| Dimension aggregation | Yes (when Field is a dimension) | How to aggregate the dimension before running (e.g. Count, Sum). |
### Use cases
- Cumulative total users over time
- Year-to-date revenue by quarter
- Running count of orders since launch
## Moving calculations
Calculate a moving calculation on a metric.
### Inputs
| Input | Required? | Description |
| --- | --- | --- |
| Moving Calculation | Yes | The function over the window (full list):**Average**: smooth a noisy trend (classic moving average).**Sum**: rolling total over a fixed lookback.**Min** / **Max**: running low or high in the window.**Count**: how many values fall in the window.**Sample standard deviation** / **Population standard deviation**: how spread out values are in the window.**Sample variance** / **Population variance**: squared spread in the window. |
| Values | Yes | The metric to compute over the moving window. |
| Order values by | Yes | The direction the window slides. Only options that fit the current viz are shown:**Rows**: table; pivot table**Columns**: pivot table**X-Axis**: line chart family, when an X-axis field is on the viz**Legend**: line chart family, when a legend/series field is on the viz**Custom**: pick dimensions to define the move directionTable with more than one dimensionPivot table with more than one row/column dimension |
| Number of values | Yes | Window size as **Previous** (behind) and **Next** (ahead), plus the current value:**Trailing** (e.g. Previous 6, Next 0): last N periods including today.**Centered** (e.g. Previous 3, Next 3): look both ways.**Leading** (e.g. Previous 0, Next 6): upcoming values only. |
| Null if not enough values | No | What to do when the window is incomplete (start/end of the series):**Checked**: return null until the window is full.**Unchecked**: use a partial window with the values that exist. |
### Use cases
- 7-day moving average of orders
- Rolling 4-week revenue sum
- Moving max of daily active users over the last 14 days
---
## Calculation builder
## Overview
A field can be viewed and edited in two modes:
- **GUI mode (calculation builder)**: an intuitive visual interface to configure a field with just a few clicks.
- **Formula mode**: an editor for the [AQL (Analytics Query Language)](/as-code/aql) behind GUI, where you can compose more advanced logic.
This page covers **calculation builder**. For Formula mode, see the [AQL](/as-code/aql) docs.
VIDEO
## Calculation configuration
### Calculation inputs
Inputs define the calculation's logic. Depending on the calculation, an input can be a field, plain text, a number, or a configuration option. See [Available calculations](/docs/calculation-builder/available-calculations) for the full list.
### Field metadata
- **Label**: the display name shown to users in reports and exploration.
- **Name**: the identifier used to reference the field in other formula.
- **Description**: a note explaining what the field represents.
### Filter
Add conditions that apply to this field only. If you want to apply a condition for all fields on a viz, use [viz conditions](/docs/data-exploration#filtering-data).
**Example:** applying the condition `status == 'delivered'` on `Total delivered orders` affects only that field; it doesn't impact other fields such as `Total orders`.
### Group by
Create a metric with a customized level of detail (how data is grouped). See [level of detail](/as-code/aql/learn/level-of-detail) for more. There are two ways to set the grouping.
**Group by all dimensions except selected ones**: the metric follows the report's dimensions, minus the ones you exclude.
- **Example:** set `Total orders` to group by all dimensions except `Country`. It totals across countries, so within a continent each gender shows the same total regardless of country.
**Group only these dimensions**: the metric is computed at exactly the dimensions you pick, ignoring the rest of the report.
- **Example:** set `Total orders` to group only by `Country`. It always aggregates to the country level, no matter which other dimensions the report adds.
### Format
Set how the field displays.
**Example:** format `% of total orders` as **Percent (2 decimals)** so a raw `0.2434` reads as `24.34%`.
## Backed by AQL
The calculation builder is powered by [AQL](/as-code/aql) underneath. If you need a metric with more advanced or custom logic, switch to **Formula mode** and compose it in AQL.
**Learn more:**
- [AQL in 30 minutes](/as-code/aql/learn-in-30-minutes)
- [AQL function cheatsheet](/reference/aql/functions)
- [AQL Cookbook](/as-code/aql/cookbook/metrics-by-example)
---
## Quick start(Calculation-builder)
You can start any of these from the **dataset panel**, **viz settings**, or the **result table**. This section focuses on creating metrics with **Calculation Builder** (GUI). For Formula mode and AQL, see the [AQL](/as-code/aql) docs.
## Create metrics from scratch
Use this when you want to create a new metric with blank settings and set it up yourself. See [Available calculations](/docs/calculation-builder/available-calculations) for the full list of options.
## Create metrics from an existing one
Use this when you want to build a new metric using an existing one as the input.
**Example:** from `Total orders` metric, create a new metric `% of total orders`.
## Aggregate dimensions into metrics
Use this when you want to summarize a dimension into a metric with a quick aggregation (e.g. count, sum, average, min, max...).
**Example:** from the `Delivery attempts` dimension, apply 75th percentile to create a metric `P75 - Delivery attempts`.
## Duplicate existing metrics
Use this when you want a variant of an existing metric with a small tweak.
**Example:** duplicate `Total orders` into a new metric, then change its filter to `Country is Australia` to make `Total orders (Australia)`.
## Create metrics with AI
Describe what you want in plain language and AI builds the metric for you, no point-and-click needed. You can easily verify it's accurate by seeing how AI built it in the chat, and if you'd like to change anything, just open the metric in the calculation builder.
**Example:** ask for `Ratio of Australia orders to total`. AI builds it as `Total orders (Australia)` ÷ `Total orders`, reusing those metrics if they exist or creating them if not.
---
## End Users Handbook (v1)
{/* Feed the right-rail "On this page" nav manually: the content lives inside a React
component, so Docusaurus can't infer headings at compile time. Sections are level 2,
the individual cards nest under them as level 3. */}
export const toc = sections.flatMap((s) => [
{value: s.title, id: s.slug, level: 2},
...s.pages.map((p) => ({value: p.title, id: p.slug, level: 3})),
]);
---
## End Users Handbook
export const toc = [
{
"value": "Getting answers quickly",
"id": "getting-answers-quickly",
"level": 2
},
{
"value": "Start with Ask AI",
"id": "start-with-ask-ai",
"level": 3
},
{
"value": "Share an Ask AI Conversation",
"id": "share-an-ask-ai-conversation",
"level": 3
},
{
"value": "Ask Follow-Up Questions",
"id": "ask-follow-up-questions",
"level": 3
},
{
"value": "Summarize a Dashboard with AI",
"id": "summarize-a-dashboard-with-ai",
"level": 3
},
{
"value": "Ask AI Across Widgets with Your Org's Context",
"id": "ask-ai-across-widgets-with-your-orgs-context",
"level": 3
},
{
"value": "Interact with AI-Generated Charts",
"id": "interact-with-ai-generated-charts",
"level": 3
},
{
"value": "Use Holistics from Your Own AI Interface",
"id": "use-holistics-from-your-own-ai-interface",
"level": 3
},
{
"value": "Exploring dashboards & reports",
"id": "exploring-dashboards-reports",
"level": 2
},
{
"value": "Filter a Dashboard or Report",
"id": "filter-a-dashboard-or-report",
"level": 3
},
{
"value": "Filter by a Relative Date or Time",
"id": "filter-by-a-relative-date-or-time",
"level": 3
},
{
"value": "Cross-Filter a Dashboard",
"id": "cross-filter-a-dashboard",
"level": 3
},
{
"value": "Filter to Top or Bottom N",
"id": "filter-to-top-or-bottom-n",
"level": 3
},
{
"value": "Drill Through to Another Dashboard",
"id": "drill-through-to-another-dashboard",
"level": 3
},
{
"value": "Refresh Data to the Latest",
"id": "refresh-data-to-the-latest",
"level": 3
},
{
"value": "KPI Metric Sheets",
"id": "kpi-metric-sheets",
"level": 3
},
{
"value": "Understand the Context Behind Your Data",
"id": "understand-the-context-behind-your-data",
"level": 3
},
{
"value": "Uncover the who and why behind a number",
"id": "uncover-the-who-and-why-behind-a-number",
"level": 2
},
{
"value": "Drill Down into a Metric",
"id": "drill-down-into-a-metric",
"level": 3
},
{
"value": "View Underlying Data",
"id": "view-underlying-data",
"level": 3
},
{
"value": "Creating & saving your own analysis",
"id": "creating-saving-your-own-analysis",
"level": 2
},
{
"value": "Explore a Report",
"id": "explore-a-report",
"level": 3
},
{
"value": "Share a Live Exploration Link",
"id": "share-a-live-exploration-link",
"level": 3
},
{
"value": "Create a Dashboard from a Dataset",
"id": "create-a-dashboard-from-a-dataset",
"level": 3
},
{
"value": "Get to Know Canvas Dashboards",
"id": "get-to-know-canvas-dashboards",
"level": 3
},
{
"value": "Find Your Way Around the Visualization Interface",
"id": "find-your-way-around-the-visualization-interface",
"level": 3
},
{
"value": "Customize Your Dashboard Editor",
"id": "customize-your-dashboard-editor",
"level": 3
},
{
"value": "Save to Personal Workspace",
"id": "save-to-personal-workspace",
"level": 3
},
{
"value": "Share a Report Internally",
"id": "share-a-report-internally",
"level": 3
},
{
"value": "Auto-Sync a Report to Google Sheets",
"id": "auto-sync-a-report-to-google-sheets",
"level": 3
},
{
"value": "Export to CSV or Excel",
"id": "export-to-csv-or-excel",
"level": 3
},
{
"value": "Build calculations, conditions & analytic functions",
"id": "build-calculations-conditions-analytic-functions",
"level": 2
},
{
"value": "Create Advanced Nested Conditions",
"id": "create-advanced-nested-conditions",
"level": 3
},
{
"value": "Create Period-over-Period Comparisons",
"id": "create-period-over-period-comparisons",
"level": 3
},
{
"value": "Create a Running Total",
"id": "create-a-running-total",
"level": 3
},
{
"value": "Add Moving Calculations",
"id": "add-moving-calculations",
"level": 3
},
{
"value": "Show Percent of Total",
"id": "show-percent-of-total",
"level": 3
},
{
"value": "Add a Trend Line",
"id": "add-a-trend-line",
"level": 3
},
{
"value": "Add a Reference Line",
"id": "add-a-reference-line",
"level": 3
},
{
"value": "Suggest Organization-Specific AI Skills",
"id": "suggest-organization-specific-ai-skills",
"level": 3
},
{
"value": "Design & style your dashboard",
"id": "design-style-your-dashboard",
"level": 2
},
{
"value": "Add Conditional Formatting",
"id": "add-conditional-formatting",
"level": 3
},
{
"value": "Wrap Content & Set Custom Labels",
"id": "wrap-content-set-custom-labels",
"level": 3
},
{
"value": "Change Colors & Style",
"id": "change-colors-style",
"level": 3
},
{
"value": "Use Custom Charts",
"id": "use-custom-charts",
"level": 3
},
{
"value": "Build Dynamic Content Blocks",
"id": "build-dynamic-content-blocks",
"level": 3
},
{
"value": "Build Rich Layouts with HTML & CSS",
"id": "build-rich-layouts-with-html-css",
"level": 3
},
{
"value": "Deliver, share & act on data",
"id": "deliver-share-act-on-data",
"level": 2
},
{
"value": "Set Up Alerts",
"id": "set-up-alerts",
"level": 3
},
{
"value": "Schedule Report Delivery",
"id": "schedule-report-delivery",
"level": 3
},
{
"value": "Create Shareable Links",
"id": "create-shareable-links",
"level": 3
},
{
"value": "Set Up Product Actions",
"id": "set-up-product-actions",
"level": 3
}
];
{/* Built from the shared Hero / CardGrid / Card layout primitives: content lives
inline here as markdown plus cards. Section headings are real markdown (##) so the
right-rail "On this page" nav is generated automatically. Each card's `media`
prop renders an inline thumbnail (videos and GIFs stay static until clicked); the
src URLs mirror src/components/BiUsersHandbook/data.js. `time` shows the estimated
time investment; `steps` powers the collapsible "Show steps" quick preview, so
readers can skim the gist without opening the full guide. The previous monolithic
BiUsersHandbook version is kept at /guides/end-users-v1 for reference. */}
## Getting answers quickly {#getting-answers-quickly}
Instead of hunting through dashboards, ask a question in plain language. Holistics surfaces trusted dashboards, charts, and answers, and can explain how to use the platform as you go.
Ask a business question and get a trusted chart or dashboard back.
Send an AI-assisted analysis to a colleague with a link.
Clarify a metric, trend, or spike from the chart itself.
Get a plain-language summary of what a dashboard is showing.
Select several widgets, ask one question across them, and get an answer shaped by your organization's own context and preferences, not generic guesses.
Hover, filter, and drill into an AI result instead of reading a static chart.
Connect Holistics to your chat or command-line tools via MCP.
## Exploring dashboards & reports {#exploring-dashboards-reports}
Go beyond a static answer. Filter, cross-filter, and slice a dashboard down to the view you need.
Narrow results by time, region, segment, or product, even by hidden fields.
Filter on moving windows like the last 7 days, this month, or year to date. The view stays current without editing the date each time.
Click a chart to filter every related chart on the dashboard at once.
Show only the highest or lowest N records, applied after your other filters.
Jump from a summary view to a related detail dashboard.
Pull the newest numbers when data has been added since the dashboard last loaded. Refresh a whole dashboard or a single widget on demand.
See the same set of key KPIs side by side across different time aggregations (day, week, month, quarter, year) in one compact view that updates automatically.
See what each metric and field actually means, straight from the definitions your BI team maintains, so you read every number with the right context.
## Uncover the who and why behind a number {#uncover-the-who-and-why-behind-a-number}
Drill into any number until a pattern jumps out, then open the records behind the spike to see the actual customers and orders. In Holistics every metric works this way by default, with no drill paths to pre-build.
Slice a number open dimension by dimension, until the pattern driving it stands out.
See the exact records behind a number, so a spike becomes the named customers or orders that caused it.
## Creating & saving your own analysis {#creating-saving-your-own-analysis}
Safe self-service. Customize, save, and share your own analysis without touching official dashboards.
Adjust dimensions and measures for your own analysis.
Share your exploration without saving it first. The URL updates as you tweak fields, and a colleague opens the exact view you're looking at.
Spin up a brand-new dashboard straight from a dataset you already have access to.
Learn the building blocks, and place filters and content anywhere you like.
Know where the chart selector, Configure panel, and toolbar actions live.
Set your default layout preferences for the editor, like whether widgets can overlap and how content is trimmed, so every dashboard you build starts the way you lay things out.
Keep your own version of an analysis, privately.
Let teammates see your saved analysis with the right access.
Keep a Google Sheet in step with a Holistics report, refreshed on a schedule, so the numbers your team works with in Sheets are always current.
Continue analysis offline, where permitted.
## Build calculations, conditions & analytic functions {#build-calculations-conditions-analytic-functions}
Add calculated fields with a point-and-click builder or with AI, layer in advanced filter conditions, apply prebuilt analytic functions like period-over-period and trend lines, or package a repeatable analysis as a governed AI skill.
Combine AND/OR grouped filters without SQL.
Compare current vs previous period in a few clicks.
Accumulate a metric across a dimension, like running revenue by month.
Smooth noisy trends with moving averages and measure change over time.
Convert a metric into contribution percentages.
Overlay a trend line to see the direction behind the data points.
Mark a target, average, or threshold line on a chart.
Package a repeated analytical workflow as a reusable, governed AI skill.
## Design & style your dashboard {#design-style-your-dashboard}
Make your dashboards clear, branded, and great to look at.
Highlight values that cross a threshold.
Tidy tables by wrapping header or cell text and renaming fields with custom labels.
Restyle a chart or table with your own colors and formatting.
Go beyond the built-in charts with open-source visualizations.
Create rich blocks like KPI cards, heatmaps, and metric trees from templates.
Design branded, web-app-like dashboard themes and layouts.
## Deliver, share & act on data {#deliver-share-act-on-data}
Get data and insights out of Holistics, delivered to inboxes and channels, shared with external viewers, or used to trigger action in other systems. Most of these are set up with your data team. Need more access to do them yourself? Ask your data team.
Be notified through email, Slack, or a webhook when a metric crosses a threshold.
Have recurring reports delivered automatically to where your team already works, from email and Slack to Google Sheets and SFTP.
Share selected reports with people outside Holistics, safely.
Trigger an operational workflow directly from a report.
---
## Holistics mechanism
# How Holistics interacts with your data
As shared in [Data Security](/docs/security-compliance/data-security), Holistics does not store a copy of your raw database data - what we save is mainly your metadata. For example, when you save a report, you are essentially saving a set of metadata on Holistics (including report query syntax, title, visualization settings, etc.)
This is what happens behind the scenes when
1. When you run a report, Holistics create a query job that will be distributed to workers and run against your database.
2. Once your database has finished processing the query, the query results will be sent to our workers. Holistics app will then display the results of the query, and apply settings (as specified in your metadata) on it.
For the more technically inclined, here's a [blog post on how we set up our job queue system](https://www.holistics.io/blog/how-we-built-a-multi-tenant-job-queue-system-with-postgresql-ruby/).
# How SQL filter works
If your report (or dashboard) has a drop-down SQL filter, our app first sends the filter's query to your database to retrieve filter values.
When the result set of the filter query is returned from your database, we insert the value into your main report query and send them to your database to get the final result.
# How cache works
To speed up your reports and dashboard, you can optionally enable caching function. At your selected time, even if there is no one manually accesses the report, Holistics will automatically load your report and store the result set in our cache server.
With caching enabled, when users open a report, instead of running a fresh query against your database, Holistics will fetch the aggregated result set from the server and display it. This will significantly reduce the report loading time, especially when your report contains complex logic or returns a large result set.
You also have visibility of when the underlying data of your report/dashboard was last cached. If you want to have the latest data, you can click on the refresh button to send a fresh query to your database for processing.
# How embedded analytics interacts with your data
All the mechanisms mentioned above also apply to Holistics' Embedded Analytics.
Holistics does not require a copy of your report viewers' identification (such as their ID, email, etc.), so there is no need to replicate your users' information into Holistics, or create a new Holistics user account whenever you onboard a new viewer.
In case you need to restrict their data views, you can create a SQL dropdown filter that fetches embedded viewers' IDs from your own database to use as filter values. Combine it with your dashboard, and you can have a setup in which viewers can only see the data they have access to.
---
## How Holistics works
If you want the design argument behind this architecture, read [Why Holistics](/docs/difference) first.
## Architecture in one paragraph
Holistics is a **thin client on top of your SQL data warehouse**. We don't store or process your raw data; your warehouse does. What Holistics adds is an [**expressive semantic layer**](/as-code/aql/) where business logic lives as composable code objects, kept durable by an [**analytics-as-code infrastructure**](/docs/analytics-as-code/) where every definition is version-controlled, reviewable, and promotable. Everything else (dashboards, AI, embedding, self-service) runs on top.

## Holistics connects directly to your SQL warehouse
Holistics requires a SQL database. Ideally this is a SQL data warehouse (Snowflake, BigQuery, Redshift, Databricks, Postgres, etc.); in some cases you can connect directly to a production database.
When a user runs an exploration, asks the AI a question, or loads a dashboard, Holistics builds a SQL query and executes it against your warehouse. The result set is then formatted and visualized. Because all heavy lifting happens in the warehouse, you get to use the storage and processing capacity you already pay for, and your raw data never leaves your system. See [Data security](/docs/security-compliance/data-security#does-holistics-store-my-data) for details.
Holistics handles the BI tier in your stack: the semantic layer plus everything that runs on top of it (dashboards, AI, embedding, self-service). For transformation, it integrates natively with [dbt](/docs/dbt-integration); for ingestion, use whatever EL/ETL tool you already run.
## The semantic layer is where business logic lives
Unlike SQL-to-chart BI tools where users write queries and turn results into charts, Holistics introduces a **semantic layer** between the warehouse and the people (and AI) that consume data.
The semantic layer itself is written in **[AML (Analytics Modeling Language)](/reference/aml/)**: a typed language purpose-built for analytics modeling. Models, dimensions, measures, datasets, and relationships are first-class language constructs (not the YAML key-value structures most BI tools use), and modules, extends, and constants keep code DRY at scale. AML is what makes the semantic layer **programmable**, the way YAML configs cannot be. See [AML vs YAML](/as-code/amql/aml-vs-yaml) for the structural contrast.
Layered on top of the AML-defined semantic layer is **[AQL (Analytics Query Language)](/as-code/aql/)**: a composable query language for metrics. Metrics are first-class objects, not SQL strings, so logic like period comparisons, cohort retention, and ratios across grains stays *inside* the metric layer instead of leaking into derived tables and spreadsheets. See [AQL vs SQL](/as-code/aql/aql-vs-sql) for the structural contrast.
When a user asks a question (through drag-and-drop, through AQL, or through Holistics AI), the request is resolved against the semantic layer, compiled to native SQL, and executed against your warehouse.
This is the foundation that makes AI reliable: AI reasons from the same composable definitions humans use, not from raw schema. See [Why Holistics AI is reliable](/docs/ai/architecture) for the full mechanism.
## Everything is code, governed by Git
Every definition is stored as code in a Git repository: models, metrics, datasets, dashboards, relationships, permissions. This is what keeps the semantic layer durable.
That means:
- **Branches** for in-progress work
- **Pull requests** for review and approval
- **Validation** at compile time, before code merges
- **Environments** to promote changes from dev to staging to prod
- **History** for every change: who, when, and why
- **Inspectable compiled SQL** so engineers can see exactly what runs
You can edit through the GUI or write code directly in [AML Studio](/docs/development/aml-studio): both write to the same code base. See [Version Control with Git](/docs/git-version-control/) and the [Analytics-as-Code overview](/docs/analytics-as-code/) for details.
## What's next
- **[Quickstart](/docs/quickstart).** The end-to-end builder workflow with videos and screenshots.
- **[Walkthrough tutorial](/tutorials/getting-started).** Build a dataset and dashboard hands-on, step-by-step.
- **[Key concepts](/docs/key-concepts).** Core abstractions and terminology.
- **[Open semantic layer](/docs/open-semantic-layer).** Query the semantic layer from notebooks, internal apps, or AI agents.
---
## Import CSV
:::info
At the moment, only **Admins** and **Data Analysts** can import CSV data in the Development workspace.
In the future, we will work to support this capability for **Viewers** and **Explorers**.
:::
## Introduction
Sometimes you might need to quickly analyze data from CSV files for temporary reporting needs. It can be an analysis on some customer survey data, or a CRM export.
This process typically includes waiting for data engineers, navigating complex ETL setups, and dealing with access issues. It can be frustrating and result in hours or even days of waiting for what should be a simple, temporary data analysis.
To address this, Holistics supports loading spreadsheet data directly into your connected database, and creating a data model ready to use.
:::tip Usage note
CSV import is designed for quickly setting up your ad-hoc analysis. It is NOT intended to replace full-fledged data integration solutions *(see [Data Models](/docs/data-model))*.
:::
## CSV import
### Initial upload
Simply choose a local CSV file for upload, configure the data if necessary, and import. You will have a new data model ready for exploration within seconds.
### Replacing your CSV data
After the initial CSV upload, you can always replace this data with a new CSV for fresh data. The new CSV should have the same column structure to ensure compatibility with existing reports and dashboards.
To do this, navigate to the Data model → **Details** tab → **Replace file** button.
## How it works
Under the hood, Holistics loads the data from your CSV files into a new table in your connected database. Then, Holistics also creates a data model from that table.
## Supported databases
:::warning Note
Make sure you have granted the **WRITE access** for your Holistics user (or service account) in your database for the import to work.
:::
Holistics currently supports importing data to the following databases:
- PostgreSQL
- Amazon Redshift
- MySQL
- Google BigQuery
- Microsoft SQL Server
- PrestoDB
- Amazon AWS Athena
- Snowflake
- ClickHouse
This feature is not yet available for other databases, such as Databricks or MotherDuck.
## Other configurations
### Set default write schema for import
You can specify the default schema for import in this file path `settings > project_settings.aml`.
```
ProjectSettings project_settings {
data_import {
default_write_schema: 'public_persisted' //<-- Your default write schema goes here.
}
}
```
Notes:
- This setting only specifies the default option. You can still change the schema when importing.
- This setting is global for all data sources.
---
## Import Model
:::caution
Since Holistics 4.0 [does not support Extract & Load use cases](/docs/product-versions/3.0-vs-4.0#notes), Import Model will not be available in Holistics 4.0.
:::
## Definition
In Holistics, Import Model is a data model created when you want to **import data from other data source into SQL database** (EL in ELT).
Under the hood, an Import Model is a data model coupled with an Extract-Load mechanism to help you load data from other data sources (CSV, Google Sheets, etc).
> **Note:** Holistics's Import Model is designed for you to quickly get started with your reporting needs, and do not intend to replace full-fledged data integration solutions.
## How It Works
When creating an Import Model, this is what happens behind the scenes:
1. Holistics connects to your third-party source via its API and extracts the requested data.
2. The downloaded data is then inserted into a destination table in your data warehouse.
3. When loading data into data warehouse, you can create a new table or append to or overwrite an existing table.
4. That table is then exposed as a data model in the Holistics modeling layer.
## Supported Data Sources
Import Model currently supports the following data sources:
- Application: Google Sheets, CSV files
- SQL database: SQL Server, PostgreSQL, MySQL, Google BigQuery, Amazon Redshift.
## Creating Import Model
First, make sure your SQL data warehouse has [write permission](/docs/connect/create-db-user#read-only-or-write-permission), so that Holistics can write the data into the DW.
Then, follow these steps:
1. Go to **Data Modeling** page, click **Create -> Add Import Model**, or click the **(+)** next to the folder that you want to put your Import Model in.
2. Select a data source type. If there is no available data source of that type, you will be prompted to connect a relevant source (visit [Data Sources](connect/#supported-databases) for more details)
3. Select the data you want to import.
4. Change Destination Settings and Sync Configuration to your liking.
5. Click **Create** to finish the process.
:::note Note:
We do not support loading multiple flat files into one data model, each file will create a new import model
:::
## Advanced Settings
Advance Settings section gives you more granular control of your data import:
### Destination Settings
#### Table Destination
Here you can specify the schema and the table name to write your data to.
- The Schema Name will default to the Default Schema that you selected when you first connected your data source.
- The Table Name by default will be prefixed with `persisted_` . If a table with the same name already existed in the schema, the new name will be suffixed with a random number to differentiate it. However, you can choose to overwrite the existed table.
For example, your Source `Table name` is **Users** and the `Default Schema` in your Destination is **public**, then your corresponding table in the destination will be **public.persisted_users**.
#### Refresh Schedule
By setting the Refresh Schedule, the persisted table can be automatically updated with new data. The default option is `Daily at 7:00`.
### Import Modes
Currently Holistics support Full, Append and Incremental/Upsert modes. For more details on how these modes work, please refer to our docs on [Storage Settings](/docs/storage-settings).
### Sync Configuration
#### Destination Column Name
Source Column names could be arbitrary if the data source is not a standardized one (for example CSV files, or Google Sheets). By default, Holistics will normalize the source column names (use all lowercase alpha-numeric characters and underscores). However, users should still pay attention to the different naming conventions supported by databases and make changes accordingly.
#### Destination Data Type
To best assist users when importing data, Holistics will:
- Map your data to one of the Generic Data Types first (Whole Number, Decimal, TrueFalse, Date, DataTime, Text)
- Then select the suitable data type in your destination database.
For example, you want to import data from SQL Server and your destination Data Warehouse is Google BigQuery. The source table in SQL Server has columns in `BIGINT`, `INT`, `SMALLINT`, `TINYINT`. What will happen in Holistics:
1. The integer columns in SQL Server are mapped to our Whole Number data type
2. Next, the Whole Number data type is mapped to `INT64` type in BigQuery
Please refer to [the Data Type Mappings section](#data-type-mapping) for more details.
In most cases, Holistics can interpret the data being loaded in and map your fields to data types supported by the destination database. However, in more complicated cases you can manually map data types by using the **Custom type** selection:
### Other config
**Nullable**
If this is checked, the column is allowed to have NULL. If unchecked, the loading operation will fail if there is a row in the column with no value. This particularly is useful when you want to validate your data logic.
By default, all the columns in Sync Configuration will be Nullable
**Delete Column**
If you want to exclude any columns from being loaded to the Destination, you can remove them here.
Currently, this option is not available for no-SQL Data Sources (Spreadsheet, CSV,...)
## Data Type Mapping
### Source Data Types and corresponding Generic Data Types
Source
Whole Number
Decimal
TrueFalse
Date
DateTime
Text
Postgres
smallint, int, serial, smallserial
double, real, decimal, numeric
boolean
date
timestamp*
varchar, char, enum, text, binary, bigint, bigserial*
BigQuery
integer
numeric, float
bool
date
datetime, timestamp
string, bytes, int64
MySQL
tinyint, smallint, mediumint, int
decimal, float, double*
n/a
date
timestamp, datetime
varchar, char, text, longtext, enum, binary, blob, varbinary, bigint*
SQLServer
tinyint, int, integer, smallint
decimal, dec, double precision, float, real, numeric
bit
date
datetime, datetime2, datetimeoffset, smalldatetime
text, nchar, varchar, nvarchar, ntext, xml, uniqueidentifier, char, character*, bigint
Google, CSV
n/a
n/a
n/a
n/a
n/a
always text
Oracledb
number*
float*
n/a
date
timestamp*
varchar, nvarchar, varchar2, char, nchar, nvarchar2, long, blob, raw, long raw
### Generic Data Types and corresponding data types in Destination
Destination
Whole Number
Decimal
TrueFalse
Date
DateTime
Text
Postgres
integer
double precision
boolean
date
timestamp without timezone
text
BigQuery
int64
float64
bool
date
timestamp
string
MySQL
integer
double precision
tinyint(1)
date
datetime
text
Oracledb
number
number
number(1, 0)
date
date
nvarchar2(1000)
SQLServer
int
real
bit
date
datetime2
ntext
:::warning Notes:
- The suggested data type is based on a sample of your data, so in some cases, it could fail if there are unexpected values in your data (for example, in a Google Sheet the first few rows can have numeric values but in a later row a string value can be mixed in.)
- If the data type cannot be interpreted, it will be mapped to Text type.
:::
## Editing Import Model
After creating the import model, you can still adjust its settings (destination and sync configuration).
You tend to edit the import model whenever:
- You have set a field's Data Type incorrectly and the import job fails
- You want to change your Destination Settings (Refresh Schedule, Import Mode...)
- ...
In those cases, click on **Edit** in **Import Settings** and the Import Model Editor view will appear.
## Refresh Source Structure of Import Model
When your sources' structure has some changes (columns are added, deleted or renamed...):
- In the case of **Google Spreadsheet imports**, we do not automatically update the Source structure for you - you will need to click on the Re-validate button to get the updated structure. Note that this will also revert all of the column names and data types to default.
- In the cases of **Database Table imports** (from PostgreSQL, MySQL, SQL Server, BigQuery), we will automatically update the Source structure for you. If there are new columns in your Source, they will appear in the Sync Configuration's field list but are not enabled. To include them in the sync, you will need to toggle them on.
---
## Slack integration
Holistics connects to Slack so your team can stay close to the data without leaving the place they already work. This page is the home for everything the Holistics Slack app can do.
## Capabilities
### Send reports and dashboards to Slack
Schedule dashboards (or individual tabs) to land in any Slack channel on a recurring frequency, with PNG or PDF attachments and customizable message content.

See [Send to Slack](/docs/delivery/slack-schedules) for setup, message formatting, and managing schedules.
### Get notified when data changes
Use [Data Alerts](/docs/delivery/data-alert) to push a Slack message the moment a metric crosses a threshold, instead of waiting for the next scheduled report.
### Ask data questions in Slack with AI
Use [Holistics MCP + Slack bot](/docs/ai/mcp-server/slack) to run a self-hosted Slack bot that answers data questions directly in a thread, powered by your AI assistant of choice.
## Connect Holistics to Slack
To integrate Slack with Holistics, go to **Organization Settings > Integrations**:

- Click **Connect to Slack** and you will be redirected to the Slack authorization page and asked for a Slack team you want to send to. This authorization process is required for any user since Holistics can only post to Slack channels to which the user has access.
- The first authorization with Slack requires an **admin role**. This authorization determines the Slack team connecting to the Holistics tenant. A Holistics tenant can connect to only 1 Slack team at a time.
To change your Slack team or disable Slack integration, go back to **Organization Settings > Integrations**.
## Manage authorizations
To view and manage all Holistics Slack authorizations of your team:
1. Visit [Holistics on Slack App Directory](https://holistics.slack.com/apps/A8KKGBABX-holistics).
2. Make sure you have selected the correct Slack workspace in the top-right corner.
3. Go to the **Configuration** tab and manage the authorizations of your team.
## Permissions and data handling
Holistics only posts to channels the authorizing user has access to. Reports are uploaded directly to your Slack workspace and follow your Slack plan's [file storage limits](https://slack.com/help/articles/115002422943-Message-file-and-app-limits-on-the-free-version-of-Slack#file-storage-limit). Download links Holistics generates for each delivery expire after **1 week**.
:::info
You may need to re-authorize Slack in Holistics to grant the file uploading permission. Visit the **Integration Settings** page, in the Slack Integration section, you will be prompted to authorize Slack again if the current token is outdated. Otherwise, if there is no prompt, the Slack token has enough permission and no further action is needed.
:::
---
## Date drills
:::tip AML Reference
Writing date drill in code? See [DateDrillBlock](/reference/aml/date-drill-block) and [DateDrillInteraction](/reference/aml/dashboard-interactions).
:::
## Introduction
Date Drills allow viewers to quickly change the time granularity (year, quarter, month, week, day, hour, minute) of reports. This is useful when you need to **zoom out** to identify a trend or **zoom in** to examine the details in day-to-day operations.
In Holistics, Date Drill will be available at two levels:
- **[Dashboard Date Drill](#dashboard-date-drill)**: Requires analysts to set up
- **[Block / Widget Date Drill](#widget-date-drill)**: Always available for blocks and widgets that meet the [requirements](#date-drill-requirements)
## Dashboard Date Drill
### When is Dashboard Date Drill useful?
As Dashboard Date Drill operates on the dashboard level, it offers certain flexibilities of a dashboard interactive feature, similar to Dashboard Filter. These include:
- Creators can selectively map several widgets to the same Date Drill at once
- The Date Drill result will persist in Export files, and it can also be set up in Schedules
### How to set up Dashboard Date Drill
Follow these steps to set up a Dashboard Date Drill:
1. **On Dashboard**: Click on the “Add Control” button, and choose “Date Drill” from the drop-down menu
2. **On the “Add Date Drill” modal**:
- (Optional) Choose a default time granularity for transformation
- Enable the widgets you want to map to
- Choose a date field for comparison in each enabled widget
### Mechanism
**Date Transformation**
When using Date Drill, the viewer is actually changing the date transformation of the date field in the report, without having to change it inside the [Dataset Exploration view](/docs/fields-calculation).
**Mapping to a Widget(s)**
Once enabled for a widget(s), Dashboard Date Drill will apply date transformation to any Date fields it is mapped to.
## Widget Date Drill
### When is Widget Date Drill useful
- You can quickly change the time granularity for a particular report as a temporary view.
### Mechanism
Same as Dashboard Date Drill, user can change the date transformation of the date field in the report with Widget Date Drill, without having to change it inside the [Dataset Exploration view](/docs/fields-calculation).
However, Widget Date Drill only provides a temporary view of the data by different time levels. Therefore, if the viewer refreshes the dashboard, the report that they previously used Date Drill on will revert to the default date transformation or Dashboard Date Drill.
### How to use Widget Date Drill
With Widget Date Drill, you change the time granularity of *one single report* at a time.
There are two ways to use Widget Date Drill:
1. Simply right-click on the report > select Date Drill > select the time granularity.
2. Hover on the report you want to Date Drill > select the Date Drill options under the report name > select the time granularity.
## Considerations
### Date Drill requirements
There are two requirements for Date Drill to be available:
1. The report must contain a date field in the X-axis/Legend (depending on the chart type)
2. (Only for Widget Date Drill) The report must be created from one of the following visualization types:
- Tabular visualization: [Table](/docs/charts/table), [Pivot table](/docs/charts/pivot-table)
- Line-family chart: [Line chart](/docs/charts/line-chart), [Area chart](/docs/charts/area-chart), [Bar chart](/docs/charts/bar-chart), [Column chart](/docs/charts/column-chart), [Combination chart](/docs/charts/combination-chart)
- Pie-family chart: [Pie chart](/docs/charts/pie-chart-donut-chart), [Pyramid chart](/docs/charts/pyramid-chart-funnel-chart#pyramid-chart), [Funnel chart](/docs/charts/pyramid-chart-funnel-chart#funnel-chart)
### Date Drill vs. Report interaction
Date Drill will override the default format of the mapped date fields in your report, unless you set the transform to "Default".
### Dashboard Date Drill mapping
Each report can be mapped to only one Dashboard Date Drill at the same time.
### Widget vs. Dashboard Date Drill interaction
Whichever Date Drill is submitted last will apply on the widget, regardless of whether it’s a Widget or Dashboard Date Drill.
However, do note that only Dashboard Date Drill persists resulting in export.
### Permission
Users of all [user roles](/docs/admin/user-roles) can use **Dashboard Date Drill** and **Report Date Drill** on internal dashboards (signed in using their Holistics accounts), [embedded dashboards](/embedded), or [dashboards shared via shareable links](/guides/user-access#share-a-dashboard).
## FAQs
### What if I have two date dimensions in the same table?
The date drill will be applied to the first date dimension it detects, usually the one in the left-most column.
### The Legacy version of Dashboard Date Drill
Previously, we had a legacy version of Dashboard Date Drill, where users had very limited control over it.
Since December 2023, a new version has been released with multiple improvements. Refer to our [**release note**](https://community.holistics.io/t/beta-release-a-new-version-of-dashboard-date-drill/1750) for details.
---
## Drill down & break down
## Introduction
Drill down and break down give your team instant analytical depth without leaving the dashboard. These interactions transform static charts into exploratory tools. Click any data point to understand what's driving the numbers.
Both features work immediately on your existing dashboards with **zero configuration required**.
## Getting started
### Break down
Break down lets you split a metric by any dimension you choose: product, region, customer segment, and more. Right-click on a chart or use **Analyze** in the toolbar to add or change breakdowns and see different perspectives of the same number.

### Drill down
Drill down is a filtered breakdown applied to a specific data point. **Right-click on any data point**, select drill-down, and the chart filters to that item and shows its breakdown by your selected dimension. Each click takes you one level deeper in the hierarchy.

### Break down context and configuration
As your needs grow, you can:
- [Customize the dimension list](#customize-the-dimension-list): Organize dimensions into logical groups and control which ones appear
- [Disable the feature](#disable-the-feature): Turn off drill down and break down for specific datasets or visualizations
- [Help AI understand your data](#bonus-help-ai-understand-your-data): Your breakdown configuration also improves AI suggestions
## Customize the dimension list
By default, the dimension list shows every available dimension from the dataset. This works fine for small datasets, but as your data model grows, users may face a long, unorganized list that makes finding the right dimension difficult.
Customizing the dimension list solves this by letting you:
- **Highlight relevant dimensions**: Surface the most useful breakdowns for each context
- **Organize into logical groups**: Group dimensions by category (e.g., locations, products, user demographics)
- **Guide the drill-down path**: Arrange fields in a natural hierarchy (e.g., continent → country → city)
Here's an example of a customized dimension list in action:
You can configure the dimension list at either the dataset level or the visualization level. This customization will apply to all metrics within the selected dataset or visualization.
### Customize at dataset level
In Development, open your dataset definition, and add your breakdown group definition under: `dataset > context > analysis > breakdown > group`
Only dimensions are allowed in `group`
```aml
Dataset ecommerce {
models: [users, orders, cities, products]
relationships: [
relationship(orders.user_id > users.id, true),
...
]
context {
analysis {
// highlight-start
breakdown{
group location {
label: 'Locations'
fields: [
r(users.continent),
r(users.country),
r(users.city),
]
}
group product {
label: 'Products'
fields: [
r(products.category),
r(products.name),
]
}
}
// highlight-end
}
}
}
```
### Customize at visualization level
In Development, open your dashboard definition, and add your breakdown group definition under: `dashboard > VizBlock > analysis > breakdown > group`
Note that this will override any configuration defined at the dataset level.
```aml
block v23: VizBlock {
label: 'Count of Users'
viz: CombinationChart {
dataset: ecommerce
x_axis: VizFieldFull {
ref: r(users.sign_up_date)
}
y_axis {
series {
field: VizFieldFull {
ref: 'count_of_users'
}
}
}
context {
analysis {
// highlight-start
breakdown {
group user_attributes {
label: 'User attributes'
fields: [
r(users.country),
r(users.city),
r(users.gender),
r(users.age_group)
]
}
}
// highlight-end
}
}
}
}
```
### Best practices
- Assign a clear and intuitive `label` to each breakdown group to improve the end-user experience.
- Arrange fields in the `group` according to the preferred order, as this will imply the drill-down hierarchy, for instance: continent > country > city.
## Disable the feature
Sometimes you need to turn off drill down and break down. For example, when a dataset contains sensitive dimensions you don't want users exploring, or when breakdowns don't make sense for certain visualizations.
You can disable the feature for an entire dataset or a specific visualization:
```aml
Dataset ecommerce {
models: [...]
relationships: [...]
metric {...}
// highlight-start
settings {
analysis_interactions {
breakdown {
enabled: false
}
}
}
// highlight-end
}
```
Note that the most restrictive configuration applies. In other words, for "Drill down" and "Break down" to be available in a visualization, it must be enabled at both the dataset and visualization levels.
## Bonus: Help AI understand your data
The breakdown groups you configure aren't just for end users. They also help Holistics AI provide smarter suggestions. When you define meaningful dimension groups, the AI uses this context to better understand your data model and deliver more relevant recommendations.
Learn more about [Context for AI](/docs/ai/context/overview).
## Supported visualizations
- Line, column, bar, area and combination charts
- Pie, donut, pyramid and funnel charts
- Pivot table
- Conversion funnel chart (only Break Down available)
## Who has access
Users with permission to explore the associated dataset can access these interactions (typically those with the [Explorer, Analyst, or Admin roles](/docs/admin/user-roles#roles-in-holistics)). To customize or disable these features, you need the Analyst or Admin role.
These features are not supported in [Shareable Links](/docs/delivery/shareable-links).
---
## Drill-through
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard Filter](/docs/filters/index.md)
:::
## Introduction
In Holistics, Drill-through allows viewers to explore additional data related to one or more data in the original report.
For example, when looking at the total revenue of the Body Category in the United States, we want to see which cities are contributing to the revenue. We also want to know the customer demographics responsible for this revenue. With drill-through, viewers can easily click through and explore this detailed data.
## Mechanism
In Holistics, drill-through is the process of linking many **source dashboard [S]** to one **target dashboard [T]** that contains information relevant to all the sources.
- **Source dashboard**: the dashboard that contains the generic data that we want to explore further. In our example, the `Category analysis` dashboard is the source dashboard, the generic data is the revenue of New York.
- **Target dashboard**: the dashboard that contains more specific information that provides us with a more detailed understanding. In our example, the `Customer Demographics` dashboard is the target dashboard, the customer emails, age groups, and gender distribution are detailed information.
Technically, we are creating a filter (Filter X, Y, Z) at the target dashboard that can take the **value** of **any field (**`field_a`**,** `field_b`**)** from **any dataset (**`DSa`**,** `DSb`**)**
Then, whenever we want to drill through from any source dashboard (that contains those fields), we will be navigated to the target dashboard where that field's value is applied as a filter (Filter X = DSa.field_a, Filter Y = DSb.field_b, Filter Z = DSa.field_b)
For example:
- We create a filter of **Category Name** and **City Name** at the target dashboard `Customer Demographic` so that they can take the value of **T-Shirts** (as category) and **New York** (as city).
- When we click on the revenue column of **T-shirts** in **New York** from `Category Analysis`, we will navigate to the dashboard that contains customer demographics and information that buys **T-shirts in New York**.
## How it works
### Set up drill-through at the target dashboard(s)
Before setting up drill-through, you should determine the source and target dashboards. In this example, we have the following dashboards:
- Source dashboard: `Category Revenue by Country`
- Target dashboards:
- `Category Analysis`
- `Customer Demographic`
#### Step 1: Create Filters at target dashboards
On the target dashboard `Category Analysis`, create `Category` and `Country` filters and select all the visualizations you want to apply the filters to.
By doing this, when you drill-through from the source dashboard `Category Revenue by Country`, you will be able to navigate to `Category Analysis` dashboard with filters `Category = Body` and `Country = United States` applied.
#### Step 2: Enable Drill-through in Filters
To enable Drill-through, simply toggle on Drill-through option in your Filters. After completing this step, you can start drilling-through from any visualizations in any dashboard that contain the field **Category**. You can customize this behavior with [advanced configurations](#advanced-drill-through-configurations).
### Use drill-through from the source dashboard
After setting up Drill-through at target dashboards, you can drill-through from the source dashboard by right-clicking on the data points.
For example, when right-clicking on the Revenue of Body Category in the United States, the values `Category = Body` and `Country = United States` are passed as filter values on the target dashboard `Category Analysis`. Similarly, when right-clicking on the the Quantity Order in Chicago by users aging from 20 to 29, the values `City = Chicago` and `Age Group = From 20 to 29` are passed as filter values on the target dashboard `Customer Demographic`.
When you set up drill-through filters at the target dashboard, you can drill from ***any*** source dashboards, as long as the source and the target dashboards containing reports that are created from the same dataset.
After navigating to the target dashboard, viewers can continue to drill-through to another target dashboard (if any) or explore the current data.
- If viewers drill-through to another target dashboard, use the left/right arrow icon on the top right to go back and forth between dashboards.
- In addition, viewers can share, download, or go to the main dashboard view to edit it by clicking on the options on the top-right corner.
## Advanced Drill-through configurations
:::info
The following configurations are only available in Canvas Dashboard. Auto mode is only available to [Field Filters](/docs/filters/field-filters).
:::
By default, Holistics automatically links all source dashboards containing widgets from the same dataset as filters on the target dashboards. To further customize this behavior, you have the following options:
### Auto (default)
* Automatically links all source dashboards containing widgets from the same dataset as filters on the target dashboards.
* Provides a quick setup with minimal configuration.
* **Disabled dashboard option:** You can exclude specific dashboards from drill-through even if they contain relevant widgets. This is useful for dashboards that might technically qualify but aren't contextually relevant.
### Custom
* Allows you to manually specify which dashboards should be linked to target dashboards.
* Holistics will only link the custom dashboards you select to target dashboards.
* Gives you precise control over drill-through behavior.
## Supported visualization types
The following visualization types are supported for drill-through:
- Table
- Pivot Table
- Line chart family: Line/Bar/Column/Area/Combination chart
- Pie chart family: Pie/Funnel/Pyramid chart
- Scatter chart family: Scatter/Bubble chart
- Custom charts (with proper setup following this [guide](/guides/create-interactive-custom-charts))
All other charts are not supported.
---
## Filter Data
Holistics supports filtering, allowing you to narrow down results by time, customer segment, product category, or any other dimension. This helps you focus on exactly what matters. without rebuilding your dashboards.
With Holistics filters, you can:
- Filter a single visualization without affecting others
- Filter multiple visualizations at once
- Track which filters are active on your dashboard
- Share dashboard with prefilled filters
Filtering is performed on the original data values, not the formatted values. For example, when you format a number as a day of the week (1 → Monday), the filter input would be `1` instead of `Monday`.
## Filter a single visualization
Use **local conditions** to filter individual visualizations without affecting the rest of your dashboard. These temporary filters are ideal for drilling into specific data points during exploration.
You can apply local conditions through right-click actions for quick filtering or the visualization toolbar for more control. Here's what you can accomplish:
### Focus on specific segments
Remove outliers that skew your analysis with exclude, or spotlight specific segments with include. Perfect for removing noise from your data or zeroing in on specific regions, time periods, or categories.
**How to apply**: Right-click on x-axis labels, legend items with dimension values, or table rows to include or exclude those values.
### Identify top and bottom performers
Instantly surface your best or worst performing segments without manual sorting. Perfect for identifying leaders and laggards in sales, customer segments, or any ranked metric.
**How to apply**: Right-click on legend items that display metric names or table column headers to apply Top N or Bottom N filters.
### Filter by value
Find all values above or below a specific threshold. Useful for spotting anomalies, identifying high performers, or filtering data that meets certain criteria.
**How to apply**: Right-click on any data point or table cell value to filter using greater than (`>`), less than (`<`), or other comparisons.
### Access more filtering options
When you need multiple conditions or want to see all available filter options, use the visualization toolbar for full control over your filtering criteria.
**How to apply**: Hover over any visualization and click the filter icon in the toolbar to access all filtering options and combine multiple conditions.
### How local conditions work
- **Filters work on fields not displayed in the visualization** - Local conditions work even when the filtered field isn't displayed in the visualization. For example, filter by customer segment without showing the segment column in your revenue table.
- **Local conditions are temporary** and reset when the page refreshes. They're ideal for quick, exploratory analysis without permanently changing your dashboard view.
## Filter multiple visualizations at once
**Dashboard filters** apply the same filter across multiple visualizations simultaneously, helping you analyze your entire dashboard through a consistent lens.
### How dashboard filters work
- **Filters work on fields not displayed in the visualization** - Dashboard filters work even when the filtered field isn't displayed in the visualization. For example, filter by customer segment without showing the segment column in your revenue table.
- **Filters can be scoped** - Dashboard filter can be configured to affect all visualization blocks on the dashboard, or limit them to specific visualizations only (see [Setup dashboard filter](/docs/filters)).
- **Local condition combine with dashboard filters** - When both exist, they use AND logic, meaning both conditions must be met for data to be rendered.
## Track and manage filters across dashboard
When working with dashboards containing a large number of visualizations and filter mappings, understanding which filters affect which visualizations can become challenging (especially as you navigate between tabs).
The **controls & filters panel** provides a centralized view to help you manage this complexity.
What you can do with the **controls & filters panel**:
- Quickly locate any filter on the dashboard
- Apply filters directly from the panel
- See changed filters (marked with purple dots)
- Reset filters at current tab, specific tabs, or entire dashboard
- View which visualizations each filter maps to and identify unmapped filters
## Share dashboard with filters
When you apply dashboard filters, the URL automatically updates to include your filter state (e.g., `dashboard/sales-overview?_fstate=qKs9pdVM`). Share this URL with others to give them the exact same filtered view (perfect for highlighting specific insights or focusing discussions on relevant data).
## Advanced Filtering Options
Holistics also supports other advanced filtering options:
- [Cross-Filtering](/docs/cross-filtering)
- [Filter with condition group](/docs/filter-with-condition-group)
- [Top/Bottom N Filters](/docs/top-bottom-n-filter)
**Other scenarios you may encounter when applying filters**
- [Show rows with no data when applying filters](/docs/filters/show-row-no-data-applying-filter)
---
*Ready to explore your data? Start with local conditions for quick exploration, use dashboard filters for broad analysis, and combine both for precise data investigation. For interactive data discovery, explore [Cross-Filtering](/docs/cross-filtering).*
---
## Manage Dashboard
## Share Dashboard
You can give users in your organization access to a dashboard, or share it with external viewers with a shareable link. For more details, please refer to the [Permission System](/docs/admin/permission-system) and [Shareable Links](/docs/delivery/shareable-links).
## Dashboard Metadata
You can find useful information about your dashboard by hovering over the 🛈 icon next to the dashboard title. The information includes:
- **Owner _(Canvas dashboard only)_**: Indicates who is responsible for the item. Holistics automatically assigns this to the creator when the dashboard is first created, and you can update it in code at any time.
- **First deployed _(Canvas dashboard only)_**: Shows the first person who deployed or published to Production. Holistics auto-generates this, and you can't change it.
- **Created _(Quick dashboard only)_**: Shows the creator and creation time. Holistics auto-generates this, and you can't change it.
- **Last editor**: The person who published this item with changes to Production.
- **Favorites**: The number of users who have added the dashboard to their favorites.
- **Views**: The number of dashboard views in the last 30 days.
- **Frequent viewers**: Top 5 users who view the dashboard most frequently in the last 30 days (excluding public users).
- [**Tags**](/docs/find-organize/tags): Labels that users create to categorize dashboards.
- **Description**: A summary to help others understand the dashboard’s purpose or contents.
Admins can hide the metadata in case of security risks. Find the `Show metadata` option in `Organization Settings` > `General Settings`.
---
## View underlying data
## Introduction
View Underlying Data helps you quickly see “what makes up this number” at the most detailed level. By examining the details behind every data point, you can make more informed, data-driven decisions.
Holistics' View Underlying Data requires zero setup and lets you flexibly tailor the underlying data table to your needs.
## Supported visualizations
- Line, column, bar, area and combination charts
- Pie, donut, pyramid and funnel charts
- KPI
- Table
- Pivot table
- Gauge chart
## Who has access to this feature
Users with permission to explore the associated dataset can view underlying data in a dashboard (typically those with the [Explorer, Analyst, or Admin roles](/docs/admin/user-roles#roles-in-holistics)).
This feature is not available to users accessing visualizations via shareable links, as they are limited to Viewer role.
To customize the underlying views or disable the feature, you must have either the Analyst or Admin role.
## Customize the underlying views
When viewing underlying data, the default table shown may not always be relevant to users' needs. Holistics enables you to **customize different views of the underlying data.**
For example, when examining the underlying data for the Revenue metric, users can select from different views, such as orders, users, or products.
You can set up the underlying views at dataset level or visualization level. Each view is attached to a metric. A metric can contain multiple views.
### In dataset
In Development, open your dataset definition, and add your view definition under: `dataset > context > analysis > underlying_data > metric > view`
Currently, only dimensions are allowed. Metrics or model measures are not supported in `view`
```aml
Dataset ecommerce {
models: [users, orders]
relationships: [
relationship(orders.user_id > users.id, true)
]
metric revenue {...}
context {
analysis {
// highlight-start
underlying_data {
metric revenue {
view list_of_orders {
label: 'List of Orders'
description: ''
fields: [
r(orders.id),
r(orders.created_date),
r(orders.quantity),
r(orders.price),
r(orders.status),
r(users.full_name),
r(products.name),
]
}
view list_of_users {
label: 'List of Users'
description: ''
fields: [
r(users.id),
r(users.sign_up_date),
r(users.full_name),
r(users.gender),
r(users.age),
r(countries.name),
r(cities.name),
r(users.email),
]
}
}
}
// highlight-end
}
}
}
```
### In visualization
In Development, open your dashboard definition, and add your view definition under: `dashboard > VizBlock > analysis > underlying_data > metric > view`
Note that this will override any configuration defined at the dataset level.
```aml
block v23: VizBlock {
label: 'AOV overtime'
viz: LineChart {
dataset: ecommerce
calculation aov {
label: 'Average Order Value'
formula: @aql revenue/order_count;;
}
x_axis: VizFieldFull {
ref: r(order_created_date)
}
y_axis {
series {
field: VizFieldFull {
ref: 'aov'
}
}
}
context {
analysis {
// highlight-start
underlying_data {
metric aov {
view aov_details {
label: 'Order details'
fields: [
r(order_master.order_id),
r(order_master.order_created_date),
r(order_master.status),
r(order_master.quantity),
r(order_master.price),
]
}
}
}
// highlight-end
}
}
}
}
```
### Best practices
- Assign a clear and intuitive `label` to each view to improve the end-user experience.
- Arrange fields in the `view` according to the preferred order, as this will determine how they appear in the underlying table.
- Use `const` ([AML Constant](/reference/aml/constant)) to define a reusable field list for the `view`. It can be utilized across various metrics and visualizations, enhancing consistency and efficiency. See example below:
```aml
// highlight-start
const order_details = [
r(orders.id),
r(orders.created_date),
r(orders.quantity),
r(orders.price),
r(orders.status),
r(users.full_name),
r(products.name),
]
// highlight-end
Dataset ecommerce {
models: [users, orders]
relationships: [
relationship(orders.user_id > users.id, true)
]
metric revenue {...}
context {
analysis {
underlying_data {
metric revenue {
view list_of_orders {
label: 'List of Orders'
description: ''
// highlight-start
fields: order_details
// highlight-end
}
}
}
}
}
}
```
## Disable the feature
In Development, you can disable the feature for an entire dataset or a specific visualization as follow:
```aml
Dataset ecommerce {
models: [...]
relationships: [...]
metric {...}
// highlight-start
settings {
analysis_interactions {
view_underlying_data {
enabled: false
}
}
}
// highlight-end
}
```
Note that the most restrictive configuration applies. In other words, for "View underlying data" to be available in a visualization, it must be enabled at both the dataset and visualization levels.
## Underlying views as context for AI
Holistics AI uses `context` to enhance its capabilities. We encourage you to configure the underlying views, as this benefits both end users and the AI, rather than relying on the default settings. Learn more about [Context for AI](/docs/ai/context/overview).
---
## Holistics Docs
## What is Holistics?
Holistics is an **AI analytics platform** for teams that need governed, trustworthy answers across dashboards, self-service, and AI.
With Holistics, you can:
- **Trust your AI analytics.** Holistics AI reasons over your governed metric definitions rather than raw tables, so answers stay consistent with the numbers your team already uses.
- **Give business users real self-service.** Business users can answer their own questions inside the governed semantic layer instead of filing analyst tickets. The layer is expressive enough to handle complex follow-ups (cohort retention, period comparisons, ratios across grains), so exploration doesn't dead-end in spreadsheets.
- **Manage analytics like software.** Every model, metric, and dashboard is defined as code, version-controlled in Git, and promoted through environments. Pull requests, code review, and rollbacks all work the way they do for application code.
## How Holistics is built differently
Most BI tools now ground their AI in a semantic layer, but the layer underneath can only express first-order queries (slice, filter, group). AI hits a ceiling on real analytical questions like period comparisons, cohort retention, and ratios across grains, and self-service stops at simple breakdowns. Holistics is built on a deeper foundation:
- **A programmable semantic layer.** Most BI tools define their semantic layer in YAML configs, which are schemaless, error-prone, and require Jinja workarounds for any reuse. Holistics uses [AML](/as-code/aml/), a typed modeling language where models, dimensions, measures, and relationships are first-class language constructs. Modules, extends, partials, conditionals, IDE tooling. A real language built for analytics modeling, not generic key-value configs.
- **A composable query language on top.** Most semantic layers treat metrics as SQL strings that can't combine or reuse. [AQL](/as-code/aql/) treats metrics as first-class composable objects, so period comparisons, cohort retention, and ratios across grains stay inside the metric layer instead of leaking into derived tables and spreadsheets.
- **Analytics-as-code infrastructure.** Most BI tools are UI-first with Git bolted on as an afterthought (if at all). In Holistics, every definition is version-controlled in Git from day one, reviewable through pull requests, and promotable through environments. Code is the source of truth.
## Where to start
New to Holistics? Start by understanding the product, then try it hands-on.
### Understand Holistics
What problem we solve, and how we solve it differently.
The architecture and the end-to-end workflow.
The terminology and core abstractions you'll see throughout.
The principles behind our product decisions.
### Try it hands-on
Build a dataset and dashboard step by step.
A live, sample workspace you can poke around in.
Write AQL and AML right in your browser.
### Evaluating something specific?
AI on top of the governed semantic layer.
Query your governed metrics programmatically.
Put dashboards and AI directly in your product.
Map LookML concepts onto Holistics.
## Stay in the loop
What shipped recently.
What's coming next.
Questions, feature requests, tips and tricks.
## Older versions of Holistics
This site covers Holistics 4.0. If you're on an earlier version:
- [Docs for Holistics 2.0](https://docs-v2.holistics.io)
- [Docs for Holistics 3.0](https://docs-v3.holistics.io)
---
## Job Queue Optimization
This page describes the techniques that Holistics employs to optimize its Job Queue and what you can do to optimize your Job Queue.
:::tip Knowledge Checkpoint
To understand how Holistics Job System works, please refer to this [documentation](/docs/jobs/queues-and-workers).
:::
## Holistics's internal mechanisms to optimize Job Queues
### Caching
When the cache data is available, Holistics will fetch the result directly from cache and will not execute a new Job.
Holistics has many caching mechanisms to optimize its performance.
You can learn more about the main caching mechanism, which is the Reporting cache, via this [link](/docs/performance/data-caching).
### Job de-duplication
If 2 users (A and B) open the same report within a short amount of time, there's a high chance a duplicate query will be sent to the system while the first query is still running. This unnecessarily overloads the system, and increases the waiting time of both users.
To avoid this from happening, Holistics has a built-in de-duplication mechanism, which works as follows:
- Every time a query job is submitted, the query hash is used to look up concurrent running jobs within the last 10 minutes to find the same query currently being executed at customer database
- If found, the job status is set to "already existed" and the job result is routed to the previous running
job with the same query.
If the first job already exists and the result is stored in cache, the caching mechanism will kick in. The second query will not be sent at all, and we will use the cached result to serve user B.
:::info Note
The exact de-duplication behavior may vary according to your version of Holistics and your Report type.
:::
## What to do when encountering slow jobs
### Optimize slow queries/reports
:::tip Important
This is often the most effective and sustainable way to optimize your Job Queue.
:::
Slow queries/reports are often the biggest culprit that occupies and blocks your Job Workers.
Thus, optimizing the slow queries/reports will help your Job Queue performance tremendously.
Please refer to our documentations on optimizing reports:
1. [Optimizing database execution](/docs/performance/troubleshooting#improvement-methods)
2. [Tips to improve reporting performance](/docs/performance)
### Cancel Running Jobs
Some type of jobs are able to be cancelled while running. This is incredibly helpful when a user accidentally farms a long-running job as an available slot in the queue will be wasted.
A running job's ability to be canceled is determined based on the job's `Source type` and `Action`.
Currently, Holistics supports canceling these types of running job:
- **DataTransform**: execute
- **DataImport**: execute
- **EmailSchedule**: execute
- **QueryReport**: execute, preview
- **DashboardWidget**: execute
For `QueryReport` jobs (`execute`, `preview`), there are also `Cancel` buttons in `Report view` and `Report editor`, which share the same effect of cancelling the job from [Jobs monitoring screen](/docs/security-compliance/data-security#how-do-i-track-what-datareports-my-team-has-accessed).
While cancelling a running job, Holistics will also try to cancel all job's `running queries` in order to save your database server's resources.
Holistics uses a simple, yet effective mechanism to cancel job's running queries. We includes the job's id as a comment in every query sent to your database server, and uses this information to identify the specific process running the query. Afterwards, Holistics will send a specific query depending on DataSource type (e.g, `pg_cancel_backend` for PostgreSQL) to kill the identified process.
### Increase your default slots for specific Job Queues
This approach cannot be done from your side since this action will require our support engineer to adjust the queue size.
If you think that your current default slot (2 concurrent jobs for data transforms for example) is not enough for your operation, please contact us via support@holistics.io and we will process your request.
### Automatically cancel unused Jobs
:::info Coming Soon
This feature is currently in development and will be released soon!
:::
Holistics offers the **Unused Job Timeout** setting that automatically cancel unused Jobs:
Explanation:
* **Unused Jobs**: Jobs that are not being used by any user's browser
* For example: A user visits a Dashboard with 10 widgets and creates 10 Jobs, then closes their browser => Those 10 Jobs are unused.
Notes:
* This auto cancellation only applies to Jobs in these [Job Queues](/docs/jobs/queues-and-workers#default-slots-for-specific-job-queues):
* `Report`
* `Embed`
* `Adhoc Query`
### Contact Holistics Support
:::info
Please see the below [section](#reporting-slow-running-jobs-to-holistics-support).
:::
## Reporting slow-running Jobs to Holistics Support
Holistics is here to help! Please send us a ticket via support@holistics.io should you encounter slow-running jobs while using our application. To help us serve you better, consider including these suggested information in your request:
:::info
**Important:** When comparing jobs’ execution time between Holistics and your database, please make sure to run those jobs using the same database user credentials.
:::
### ✅ Attach your support request with the ID of the job
Holisitics offers [Job Monitoring dashboard](/docs/monitoring/job-monitoring) which records every job executed.
Please access this dashboard to find the ID of the slow-running job and send it to us.
### ✅ Share with us your thoughts on what you've expected of the job's performance
We would love to gain insights on your expectations of the job's execution speed. If possible, we are keen to know why you have adopted these expectations (i.e that is the normal performance of the applications that you have used).
### ✅(Optional) Include a before/ after log that demonstrates a performance degradation
If your normal jobs become noticeably slower, you can add a before/ after log showing the differences in these jobs’ duration (using the Job Monitoring dashboard like above).
### ✅ (Optional) Check the job logs to see which steps take long time to finish
You can see the detailed logs of your job by selecting the Logs button in Jobs Monitoring dashboard. This log is helpful to pinpoint what step(s) is slow.
## Other settings to control Job Queue resources
Below are the settings that allow you to have more control over your Job Queue resources.
### Limit Number of Report Jobs Run Per User
Since the max concurrent jobs are being capped per queue, when a user A purposely or accidentally farms multiple jobs, these jobs quickly take up all the available slots in the queue, causing other users unable to use the system.
To prevent this from happening, admins can set a limit on how many report jobs each user can run **concurrently**. This is done in the Admin, Settings page.
:::caution Disclaimer
This Limit setting is most suitable when you have many Users using many **different** Reports.
Otherwise, Job De-duplication may apply and actually result in your Users waiting longer. See explanation [below](#how-report-jobs-per-user-limit-works-with-job-de-duplication).
:::
#### How Report-Jobs-Per-User-Limit works with [Job De-duplication](#job-de-duplication)
When multiple Users use the **same** Reports, their Jobs would be [de-duped](#job-de-duplication) and only the first Jobs are executed.
So for example, if the `Report-Jobs-Per-User-Limit is 5`, then this can happen:
1. `User A` visits `Dashboard X` with **10 widgets** -> create 10 Report Jobs `(ID 1 to 10)`.
* Since the Limit is 5, only the first 5 Jobs `(ID 1 to 5)` will be **Running**, and the latter 5 `(ID 6 to 10)` will be **Pending**.
2. `User B` visits `Dashboard X` -> create 10 new Report Jobs `(ID 11 to 20)`.
3. The system detects that the Jobs with `ID 11 to 20` are the same with Jobs `ID 1 to 10`.
* -> It de-dups those Jobs and hence `User B` would also use **the same 10 Jobs** of `User A` `(ID 1 to 10)`.
4. -> Although there are **2 concurrent Users**, there are **only 5 Report Jobs** `(ID 1 to 5)` running at a time.
### Limit the Query Timeout of your Database Connection
Having a limit on the Query Timeout would help prevent slow queries from occupying the Job Queue for too long and block the other Jobs.
Please refer to this [documentation](/docs/connect/query-timeout) to configure the Query Timeout of your Database Connection.
**Example Scenario**
Let's say you have a Dashboard of 25 Reports, each Report takes 1 second to run.
By default, the Report Job Queue has 10 slots.
Therefore, when you open the Dashboard (and the cache is not available):
1. At t = 0, 10 Reports will be executed first in parallel.
2. At t = 1 second, the first 10 Reports will have finished, so the next 10 Reports will be executed in parallel.
3. At t = 2 seconds, the second 10 Reports will have finished, so the last 5 Reports will be executed next in parallel.
4. At t = 3 seconds, the last 5 Report will have finished.
-> It takes 3 seconds in total to run your whole Dashboard.
Then, an Analyst makes some modifications to the first 10 Reports, causing them to take 5 seconds to run.
Without any Query Timeout, those first 10 Reports will occupy the whole Job Queue for 10 second and delay the other Reports.
As the result:
* It now takes 7 seconds in total to run your whole Dashboard
* It takes 6 seconds to see the result of Report 11, even when Report 11 takes only 1 second to run
With a Query Timeout of 1 second, the first 10 Reports will be terminated after 1 second, freeing the Job Workers in the Job Queue for the other Reports:
Thus, the Query Timeout configuration can be handy for you to control the Job Queue.
:::info Notes
Query Timeout can also help protect your Database resources from too much load.
:::
:::caution Notes
If a query keeps timing-out, it is best to [optimize the query](#optimize-slow-queriesreports) or quarantine it (e.g. by moving it to your personal workspace or deleting it)
to avoid unnecessary load whenever opening the Report using that query.
:::
---
## Job controls
## Cancel jobs
* In the running UI for your Job, there will be a Cancel button:
* (For Admins only) To cancel any Job in your Holistics workspace:
1. Go to [Job Monitoring](/docs/monitoring/job-monitoring)
2. Cancel the target Job(s)
* To cancel all Pending Jobs, click the **Cancel Created Jobs** on the top-right corner of the page
* To cancel a specific Job, click the **Cancel** button on the right end of a Job entry
How cancellation of Running Jobs works
Some types of jobs can be canceled _while Running_.
While canceling a running job, Holistics will also try to cancel all running queries of that job to save your database server's resources.
Holistics uses a simple, yet effective mechanism to cancel a job's running queries. We include the job's ID as a comment in every query sent to your database server and use this information to identify the specific process running the query. Afterward, Holistics will send a specific query depending on the DataSource type (e.g., `pg_cancel_backend` for PostgreSQL) to kill the identified process.
## Automatically cancel unused jobs
:::info Notice
This feature is currently in Beta!
:::
In your workspace's **Admin Settings**, Holistics offers the **Unused Job Timeout** setting that automatically cancels unused jobs:
Demo
Explanation
* **Unused Jobs**: Jobs that are not being used by any user's browser
* For example: A user visits a Dashboard with 10 widgets and creates 10 Jobs, then closes their browser => Those 10 Jobs are unused.
Notes
* This auto cancellation only applies to Jobs in these [Job Queues](/docs/jobs/queues-and-workers#default-slots-for-specific-job-queues):
* `Report`
* `Embed`
* `Adhoc Query`
* What if my users want to keep the Jobs running in the background (e.g. to warm up [cache](/docs/performance/data-caching))?
* Implicitly keeping the Jobs running makes it hard to control the resources.
* Thus, if it's really necessary, please advise your users to do these instead:
* Run Holistics in multiple browser tabs
* Use [Dashboard Preload](/api/v2/reference/dashboards-submit-preload)
* Use [Scheduled Deliveries](/docs/delivery/external)
## Disable dashboard auto-run
Disable **Dashboard auto-run** also helps avoid spawning unnecessary jobs.
:::info
Refer to [Dashboard auto-run](/docs/dashboards/settings#dashboard-auto-run).
:::
## Increase your default slots for specific job queues
This approach cannot be done from your side since this action will require our support engineer to adjust the queue size.
If you think that your current default slot (2 concurrent jobs for data transforms for example) is not enough for your operation, please contact us via support@holistics.io and we will process your request.
Note that it may add additional billing/commercial terms to your Holistics subscription.
References:
* [Default slots of Job Queues](/docs/jobs/queues-and-workers#default-slots-for-specific-job-queues)
* [Monitoring Job Queues and Workers](/docs/monitoring/job-monitoring#monitoring-job-queues--workers)
## Limit the query timeout of your database connection
Having a limit on the Query Timeout would help prevent slow queries from occupying the Job Queue for too long and block the other Jobs.
Please refer to this [documentation](/docs/connect/query-timeout) to configure the Query Timeout of your Database Connection.
Example Scenario
Let's say you have a Dashboard of 25 Reports, each Report takes 1 second to run.
By default, the Report Job Queue has 10 slots.
Therefore, when you open the Dashboard (and the cache is not available):
1. At t = 0, 10 Reports will be executed first in parallel.
2. At t = 1 second, the first 10 Reports will have finished, so the next 10 Reports will be executed in parallel.
3. At t = 2 seconds, the second 10 Reports will have finished, so the last 5 Reports will be executed next in parallel.
4. At t = 3 seconds, the last 5 Report will have finished.
-> It takes 3 seconds in total to run your whole Dashboard.
Then, an Analyst makes some modifications to the first 10 Reports, causing them to take 5 seconds to run.
Without any Query Timeout, those first 10 Reports will occupy the whole Job Queue for 10 seconds and delay the other Reports.
As the result:
* It now takes 7 seconds in total to run your whole Dashboard
* It takes 6 seconds to see the result of Report 11, even when Report 11 takes only 1 second to run
With a Query Timeout of 1 second, the first 10 Reports will be terminated after 1 second, freeing the Job Workers in the Job Queue for the other Reports:
Thus, the Query Timeout configuration can be handy for you to control the Job Queue.
:::info Notes
Query Timeout can also help protect your Database resources from too much load.
:::
:::caution Notes
If a query keeps timing out, it is best to optimize the query or quarantine it (e.g. by moving it to your personal workspace or deleting it)
to avoid unnecessary load whenever opening the Report using that query.
:::
## Limit number of report jobs run per user
Since the max concurrent jobs are being capped per queue, when user A purposely or accidentally farms multiple jobs, these jobs quickly take up all the available slots in the queue, causing other users unable to use the system.
To prevent this from happening, admins can set a limit on how many report jobs each user can run **concurrently**.
You can find this setting in your workspace's **Admin Settings**.
:::caution Disclaimer
This Limit setting is most suitable when you have many Users using many **different** Reports.
Otherwise, Job De-duplication may apply and result in your Users waiting longer. See the explanation below.
:::
How Report-Jobs-Per-User-Limit works with Job De-duplication
When multiple Users use the **same** Reports, their Jobs would be [de-duped](/docs/jobs/deduplication) and only the first Jobs are executed.
So for example, if the `Report-Jobs-Per-User-Limit is 5`, then this can happen:
1. `User A` visits `Dashboard X` with **10 widgets** -> create 10 Report Jobs `(ID 1 to 10)`.
* Since the Limit is 5, only the first 5 Jobs `(ID 1 to 5)` will be **Running**, and the latter 5 `(ID 6 to 10)` will be **Pending**.
2. `User B` visits `Dashboard X` -> create 10 new Report Jobs `(ID 11 to 20)`.
3. The system detects that the Jobs with `ID 11 to 20` are the same as Jobs `ID 1 to 10`.
* -> It de-dups those Jobs and hence `User B` would also use **the same 10 Jobs** of `User A` `(ID 1 to 10)`.
4. -> Although there are **2 concurrent Users**, there are **only 5 Report Jobs** `(ID 1 to 5)` running at a time.
---
## Job deduplication
If 2 users (A and B) open the same report within a short amount of time, there's a high chance a duplicate query will be sent to the system while the first query is still running. This unnecessarily overloads the system and increases the waiting time of both users.
To avoid this from happening, Holistics has a built-in de-duplication mechanism, which works as follows:
- Every time a query job is submitted, the query hash is used to look up concurrent running jobs within the last 10 minutes to find the same query currently being executed at the customer database
- If found, the job status is set to "already existed" and the job result is routed to the previous running
job with the same query.
If the first job already exists and the result is stored in the cache, the caching mechanism will kick in. The second query will not be sent at all, and we will use the cached result to serve user B.
:::info Note
The exact de-duplication behavior may vary according to your version of Holistics and your Report type.
:::
---
## Job queue system and workers
## What is a worker/concurrent worker?
A worker (or concurrent worker) is an actor that actively processes jobs pushed into the queue. It sequentially handles jobs in the queue, with an available worker picking up the next job and processing it. Upon completion, the worker releases the job and proceeds to the next one.
### How does it work?
In Holistics, when a user opens a report, we construct an SQL query sent to the customer's data warehouse, wait for it to finish, and visualize the results.
Since the analytical SQL queries take time (seconds to minutes), it is usually not a good idea to handle this using synchronous web requests. A more scalable solution is to use a *background job queue system.*
A typical flow would look like:
1. When a user views a report, a job is created and pushed into a job queue.
2. A worker picks up the job, constructs the SQL queries, and then runs them against the customer’s data warehouse
3. Once the query is finished, the result set is visualized and presented to the user’s browser.
**What kind of actions will create a job?**
Usually actions that involve running a SQL against the customer’s data warehouse:
- Users viewing dashboards
- Email schedules triggered
- Etc.
### Why are concurrent workers important?
In an extreme scenario, with 20 users accessing 100 charts simultaneously, the Holistics application, without control, would generate 2000 database queries to the customers' database, potentially causing a crash, especially for a production database.
Holistics workers actively manage concurrent database queries by limiting the customer to 5 workers. This ensures that no more than 5 queries run simultaneously, with others queued up.
Therefore, increasing Concurrent Workers improves the querying process for both you and your customers. As your business scales, being charged based on Concurrent Workers is more cost-effective than the number of visualizations processed.
## Job queues
### Type of job queues
_Each Holistics customer has their job queue and workers_. This ensures one customer overloading the job queue will have zero to little effect on other customers’ systems.
Furthermore, depending on the nature of the job, it will be classified into different queues (or pools). For example, a **Report job** runs in a different queue than a **Data Transform job.**
### Default slots for specific job queues
Below is the default list of job queues and their default worker count. This is a soft limit, which means that it can be increased by purchasing more workers.
Queue
Default Slot
Action included
Default
20
1. Create/Update Custom Field2. Refresh Models and Dependant Models
Adhoc Query
5
1. Adhoc SQL executions
2. Dataset explorations
Filter
3
1. Filter suggestion
2. Process filter in Dashboard
Report
20
Execute report/widget
Prefetch
12
1. Prefetch Filter Cache 2. Preload Dashboard
Preview
3
1. Validate Data Import 2. Preview Report/Query (Holistics Version < 3.0)
Export
10
1. Export Dashboard 2. Export Dashboard Widget/Report
Email Schedule
2
Executing schedule (Email, Slack, SFTP, Google Sheet)
Data Source
15
1. Test Data Source connection
2. Synchronize database schema
Data Import (Version 3.0 and below)
2
Executing Data Import
Data Transform
2
Executing Data Transform (or Query Model Persistence)
Validate
5
1. Validate Table Structure in Data Transform
2. Validate Query in Data Transform
3. Preview Data Transform
Embed Analytics Queue
Default Slot
Action included
Embed
0
1. Execute Embedded Dashboard Widget/Report
2. Export Embedded Dashboard
3. Export Embedded Dashboard Widget/Report
If you want to enable our Embedded Analytics feature, please refer to our doc about [Embedded Analytics](/embedded/) for more information.
Your account’s configuration might be different from the default above. Please contact us by sending an email to [support@holistics.io](mailto:support@holistics.io) to find out your current setup.
Do note that the Embedded Analytics feature utilizes a special type of worker called Embed Worker. They are separate and can be manually adjusted from the Embed Analytics Manager.
:::info Tip
To view the exact number of Job Workers in your workspace, go to [Job Queues & Workers Monitoring](/docs/monitoring/job-monitoring#monitoring-job-queues--workers)
:::
### Life cycle of a job
:::info New Job Statuses
We have rolled out new Job Statuses to make them more intuitive.
Please refer to this [Community post](https://community.holistics.io/t/survey-on-new-holistics-job-statuses/1269) for more details.
Note that Holistics [APIs](/api) still use the old Job statuses (created and queued).
:::
Status
Description
API value
Pending
This job is waiting for an available job worker in your workspace.
created
Starting
This job is done waiting (queuing) and being picked up (started) by an available job worker. It is going to be executed shortly.
queued
Running
This job is being executed by a job worker.
running
Success
If the job runs successfully, it will have success status.
success
Failure
If the job runs unsuccessfully, it will have failure status.
failure
Cancelling
While a job is running, if you manually cancel the job, it will have cancelling status.
canceling
Cancelled
If the job is cancelled successfully, it will have cancelled status.
cancelled
Existed
When a job have this status, it means that this job coincides with the another existing Pending/Starting/Running job.
(See Job de-duplication)
already_existed
### Monitoring
:::info
To monitor your Holistics Jobs and Job Workers in real-time, please head to [**Job Monitoring**](/docs/monitoring/job-monitoring).
:::
## FAQs
### Can we reallocate some or all of the internal workers to be embedded workers?
Our core business model revolves around **internal self-service analytics**, with embedded analytics serving as a complementary add-on. We **have not**, from a commercial perspective, **accommodated** the transfer of workers or focused on supporting embedded dashboards.
You can purchase additional embedded workers for your embedded dashboards. By doing so, you'll ensure sufficient spare capacity, preventing customers from waiting for workers to be freed up when using the dashboard concurrently.
If your plan does not cater for embedded analytics, please fill out this form and our team will contact you regarding your options.
---
## How to report slow-running jobs
Holistics is here to help! Please send us a ticket via support@holistics.io should you encounter slow-running jobs while using our application. To help us serve you better, consider including these suggested information in your request:
:::info
**Important:** When comparing jobs’ execution time between Holistics and your database, please make sure to run those jobs using the same database user credentials.
:::
### ✅ Attach your support request with the ID of the job
Holistics offers [Job Monitoring dashboard](/docs/monitoring/job-monitoring) which records every job executed.
Please access this dashboard to find the ID of the slow-running job and send it to us.
### ✅ Share with us your thoughts on what you've expected of the job's performance
We would love to gain insights on your expectations of the job's execution speed. If possible, we are keen to know why you have adopted these expectations (i.e that is the normal performance of the applications that you have used).
### ✅(Optional) include a before/ after log that demonstrates a performance degradation
If your normal jobs become noticeably slower, you can add a before/ after log showing the differences in these jobs’ duration (using the Job Monitoring dashboard like above).
### ✅ (Optional) check the job logs to see which steps take long time to finish
You can see the detailed logs of your job by selecting the Logs button in Jobs Monitoring dashboard. This log is helpful to pinpoint what step(s) is slow.
# References
* [Job Queues and Workers](/docs/jobs/queues-and-workers)
---
## [Beta] Controlling filter and grouping paths
## Introduction
When you explore data in Holistics, **using a dimension with a metric means the metric gets grouped or sliced by that dimension**. If the dimension and metric come from different models, Holistics uses the relationships between them to generate the proper joins alongside the grouping.
As long as a relationship path exists between two models, Holistics will find a way to join them. This is usually what you want, but sometimes [**combining a dimension and metric from different models doesn't make analytical sense**](/docs/modeling/modeling-patterns/control-dimensions-for-metric#problem-invalid-metric-breakdowns).
This document explains how to control the filtering and grouping behavior between models.
## How to control filter and grouping behavior
Relationships in a dataset have a property called `filter_direction` that controls which direction filters and groupings can flow between two models.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [dim_users, fact_orders]
relationships: [
//highlight-next-line
relationship(fact_orders.user_id > dim_users.id, true, 'one_way')
// ^^^^^^^^
// 'one_way' or 'two_way'
// defaults to 'two_way' if not specified
]
}
```
### Available values
| Value | Behavior | When to use |
|-------|----------|-------------|
| `one_way` | Filters and groupings flow only from the "one" side (dimension) to the "many" side (fact). | Standard dimension to fact relationships. Use this as your default for * [Star schema](/docs/modeling/modeling-patterns/star-schema) or * [Galaxy schema](/docs/modeling/modeling-patterns/galaxy-schema). |
| `two_way` | Filters and groupings can flow in both directions. This is the default if not specified. | * [Many-to-many](/docs/relationships#handling-many-to-many-n-n-relationship) relationships * 1:1 relationships |
:::info Default behavior
If you don't specify `filter_direction`, it defaults to `two_way`.
:::
### How it works
Given a relationship `fact_orders.user_id > dim_users.id`:
- With `one_way`: You can group `fact_orders` metrics by `dim_users` dimensions, but not the reverse. The dimension can filter and segment the fact, but the fact cannot reach back to filter or segment the dimension.
- With `two_way` (default): You can group in both directions. This allows more flexibility but can create unintended join paths in complex schemas.
## Example use cases
### When to use `one_way`
The most common use case for `one_way` is preventing invalid metric and dimension combinations in multi-fact setups like [galaxy schemas](/docs/modeling/modeling-patterns/galaxy-schema). When you have multiple fact tables sharing common dimensions, bidirectional relationships can create unintended join paths that produce misleading results.
For example, in an e-commerce dataset with `fact_orders` and `fact_inventory` both connected to `dim_products`, you want to ensure that inventory metrics can only be grouped by product dimensions, not by user or order dimensions.

```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [
dim_users, dim_products, dim_cities,
dim_merchants, dim_categories,
fact_orders, fact_inventory
]
relationships: [
//highlight-start
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'one_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
//highlight-end
]
}
```
For a detailed walkthrough of this scenario, see [Controlling which dimensions can be used with a metric](/docs/modeling/modeling-patterns/control-dimensions-for-metric).
### When to use `two_way`
Use `two_way` when you genuinely need filters and groupings to flow in both directions between two models. Common scenarios include:
#### Dimension to dimension analysis
When you need to analyze one dimension filtered by another dimension, traversing through a fact table, you may need `two_way` to allow the filter to flow in both directions.
Consider this model: `dim_users → fact_orders ← dim_products`. If you want to answer questions like "**How many unique products (from a specific category) were purchased by each user age group?**", the query needs to traverse from `dim_users` through the fact tables to reach `dim_products`.
If all relationships are set to `one_way`, the filter flows from `dim_users` to `fact_orders`, but stops there. It cannot continue to `dim_products` because that would require flowing in the reverse direction. To enable this traversal, you would need to set the relationship between `fact_orders` and `dim_products` to `two_way`.

```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [dim_users, fact_orders, dim_products]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
//highlight-next-line
relationship(fact_orders.product_id > dim_products.id, true, 'two_way')
]
}
```
#### 1:1 relationships
When two models have a true one-to-one relationship, there's no risk of data fan-out in either direction, so `two_way` is appropriate.
For example, if each user can only be the admin of one merchant, and each merchant can only have one admin user, the relationship between `dim_users` and `dim_merchants` is 1:1. Grouping merchants by user attributes or users by merchant attributes will never inflate the results.
:::note
For 1:1 relationships, `filter_direction` is always `two_way` and cannot be changed to `one_way`.
:::
## Overriding filter direction at the metric level
Sometimes you want to keep `one_way` as the default for safety, but allow specific metrics to traverse in the reverse direction. You can do this using [`with_relationships()`](/reference/aql/with_relationships) to override the filter direction for individual metrics.
For example, say you want to answer: "What's the latest user sign-up date for each order status?" This query needs to go from `fact_orders.status` to `dim_users.sign_up_date`, which is the reverse of the normal dimension to fact flow. If the relationship is set to `one_way`, this query would be blocked.
Instead of changing the dataset relationship to `two_way` (which would affect all queries), you can override it just for this metric:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
// ... models and relationships ...
metric latest_user_signup_by_order_status {
label: "Latest User Sign-up Date"
definition: @aql
max(dim_users.sign_up_date)
| with_relationships(
relationship(fact_orders.user_id > dim_users.id, true, 'two_way')
)
;;
}
}
```
This approach keeps the default `one_way` protection for all other queries while allowing this specific metric to use bidirectional traversal.
## Row-level permission and filter direction {#rlp-propagation}
[Row-level permission](/docs/access-control/row-level-permission) (RLP) filters travel through your relationships the same way user filters do: by default, they obey the relationship's `filter_direction`. On `one_way` setups this can block permission rules from reaching every query they need to filter. The `rlp_propagation` property lets you control the two behaviors separately.
### The problem: RLP cannot reach dimensions behind a one-way relationship
Let's return to the ecommerce dataset from [When to use `one_way`](#when-to-use-one_way), now with a `total_orders` metric and a permission rule on `dim_cities.region` so each user only sees data for their permitted regions. Here the rule is defined with [row-level permission as-code](/docs/access-control/row-level-permission-as-code); the behavior is the same for rules created through the UI:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [
dim_users, dim_products, dim_cities,
dim_merchants, dim_categories,
fact_orders, fact_inventory
]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'one_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
]
//highlight-start
metric total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(fact_orders.id);;
}
permission regional_access {
field: r(dim_cities.region)
operator: 'matches_user_attribute'
value: 'region' // user attribute
}
//highlight-end
}
```
Exploring `total_orders` on its own works as expected:
```aql
explore {
measures {
total_orders
}
}
```
The permission filter flows `dim_cities → dim_users → fact_orders`, following the one-way directions all the way, and the total covers only the permitted regions.
But break `total_orders` down by `dim_products.name` (without any city field):
```aql
explore {
dimensions {
dim_products.name
}
measures {
total_orders
}
}
```
This query fails with:
> Some permission rules are not applied in this Explore. This is likely due to a missing relationship between the models.
Here's why. When a permission rule is set up on a model, Holistics checks every Explore against it: if the Explore uses a field from a model that the permission rule's model cannot filter or group, Holistics raises this error instead of silently returning unfiltered data. That check covers breakdowns too, because each dimension you break down by triggers a separate query that fetches that dimension's values.
For the `dim_products` value fetch, the permission filter would have to travel `dim_cities → dim_users → fact_orders → dim_products`, and the last hop runs against the `one_way` direction of the `fact_orders > dim_products` relationship. That value-fetch query cannot be filtered, so the whole Explore is blocked.
The result feels unpredictable to end users: a metric alone works, adding a product breakdown fails, and adding a city field on top makes it work again (the `dim_cities` fetch can be filtered directly). It also puts modelers in an unfair spot, since `one_way` is exactly what we recommend for [controlling which dimensions can be used with a metric](/docs/modeling/modeling-patterns/control-dimensions-for-metric).
### The solution: `rlp_propagation`
Relationships have an `rlp_propagation` property that controls how permission filters flow, independently of `filter_direction`:
| Value | Behavior |
|-------|----------|
| `'inherit'` (default) | Permission filters follow the relationship's `filter_direction`. |
| `'two_way'` | Permission filters flow in both directions, regardless of `filter_direction`. |
| `'one_way'` | Permission filters flow only from the "one" side to the "many" side, even when `filter_direction` is `'two_way'`. |
To fix the scenario above, allow permission filters to traverse the blocking relationship in both directions:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
//highlight-next-line
relationship(fact_orders.product_id > dim_products.id, true, 'one_way', rlp_propagation='two_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
]
}
```
Now the `dim_products` value fetch can be filtered through `fact_orders`, the permission check passes, and breaking `total_orders` down by product works, showing only the products the user is allowed to see data for.
Crucially, this changes security propagation only. End users still cannot group or filter against the `one_way` direction, so the guardrails you set up with `filter_direction` stay intact.
### When to use `'one_way'`
The reverse override is also available. If a relationship needs `filter_direction: 'two_way'` for analytical reasons (such as [dimension-to-dimension analysis](#dimension-to-dimension-analysis)) but you want permission filters pinned to the standard dimension-to-fact direction, set `rlp_propagation='one_way'` on it.
---
## How Holistics handles joins
## Introduction
For business users that use drag-and-drop report interface day by day, it may not be that important to understand what is happening behind the scenes as long as "it works".
However, for analysts, it is important to understand fully Holistics' underlying mechanism for joining to ensure the results are calculated correctly.
## How we generate SQL based on different join types
By default, Holistics uses `LEFT JOIN` (also known as `LEFT OUTER JOIN`) for both `many-to-one` and `one-to-one` relationships. For `many-to-one`, the model on the "many" side goes on the left of the join. If you have verified that a relationship's keys always match, you can opt into `INNER JOIN` instead with [`nullable=false`](#nullable-relationships).
Consider an example where we want to **count total revenue generated by different genders of the users**.
Using Data Exploration, the result may look like this:

The final SQL will look like this:
```sql
SELECT
U.gender,
SUM(O.revenue) as total_orders
FROM orders O
LEFT JOIN users U ON O.user_id = U.id
GROUP BY 1
```
:::info Why LEFT JOIN from the "many" side to the "one" side?
This preserves rows from the "many" table even when there's no matching record on the "one" side (referential integrity). In the example above, orders whose `user_id` doesn't exist in `users` are still counted.
:::
## Potential fan-out issues with one-to-many relationship
Fan-out happens when you **LEFT JOIN** two data models with **one-to-many** relationship, but the table with `one column` is on the left and the table with `many column` on the right.
For example, you are working on an ecommerce dataset that contains two data models `Order Items` and `Orders`.
Let's say when exploring this set of Data, if you use any field from `order_items`, for example, `order_items.order_id` then **delivery_attempts** will be duplicated and normally sum on the number of delivery will be wrong.
Since our current mechanism will find a join path (LEFT JOIN by default) from the data model that has aggregations (measures) to the data model that does not, the fan-out issue occurs.
In the aforementioned case, you use measure field `SUM(orders."delivery_attempts")` of **orders** data model and non-measure field `order_items.order_id` of **order_items** data model.
Since the relationship between `orders` and `order_items` is **one-to-many**, the underlying query will be generated as below
```sql
SELECT
T1."order_id" AS "order_id",
SUM(T0."delivery_attempts") AS "delivery_attempts"
FROM
"ecommerce"."orders" T0
LEFT JOIN "ecommerce"."order_items" T1 ON T0."id" = T1."order_id"
WHERE
T0."id" < 2200
GROUP BY 1
ORDER BY 2 DESC
LIMIT 500
```
### What have we done to solve the issue
:::note Note:
Holistics would auto-resolve this fan-out issue for the Aggregations and Business Calculation in our Dataset exploration UI or measure defined using AML (only in 4.0). However, we do not support solving the fan-out problems when using measures created in the 3.0 Data Model layer.
:::
In order to solve this issue, Holistics has modified the underlying query to get the distinct value when the fan-out issue happens.
Specifically, when the measure/aggregation is on the dimension model instead of the fact model, we will calculate the measure first inside the dimension model before executing the join.
For example, let take the example above, when we sum the `delivery_attempts`, this query will run first inside `order` model to calculate total `delivery_attempts` for each order_id. Let's call this Set A
```sql
SELECT
T0."id" AS "order_id",
SUM(T0."delivery_attempts") AS "total_delivery_attempts"
FROM
"ecommerce"."orders" T0
WHERE
T0."id" < 2200
GROUP BY 1
ORDER BY 2 DESC
```
After that, we will execute the join from `order_items` to `orders` as normal and select Distinct `order_id` from `order_items` model. Let's call this Set B
```sql
SELECT
distinct T0."order_id" AS "order_id"
FROM
"ecommerce"."order_items" T0
LEFT JOIN "ecommerce"."orders" T1 ON T0."order_id" = T1."id"
WHERE
T0."id" < 2200
```
Finally, we will execute Inner Join Set A and Set B, select `order_id` from Set B and `total_delivery_attempts` from Set A. By doing this, the order_id will be unique and fan-out issue no longer exists.
The final query will be:
```sql
With total_delivery_attemps as (
SELECT
T0."order_id" AS "order_id",
SUM(T0."delivery_attempts") AS "total_delivery_attempts"
FROM
"ecommerce"."orders" T0
WHERE
T0."id" < 2200
GROUP BY 1
ORDER BY 2 DESC
)
, distinct_order_id as (
SELECT
distinct T0."order_id" AS "order_id"
FROM
"ecommerce"."order_items" T0
LEFT JOIN
"ecommerce"."orders" T1 ON T0."order_id" = T1."id"
WHERE
T0."id" < 2200
)
SELECT
T1."order_id"
T0."total_delivery_attempts"
FROM
total_delivery_attemps T0
INNER JOIN
distinct_order_id T1 on T0."order_id" = T1."order_id"
```
### How to avoid fan-out issue in your report
In Holistics, the fan-out warning should only appear when you're using a custom measure, because we only auto-handle fan-out for measures with an explicit aggregation type. If your measure is created inside modeling layer using SQL, there will be high possibility that we cannot guess our aggregation function thus causing the fan-out issues.
There are some solutions to avoid the error in this situation:
- Use Aggregation or Business calculation on the Dataset Exploration
- Using AML syntax (only in 4.0)
You could find more detail [here](/docs/joins/troubleshooting-fanout)
## Referential integrity and join types
Take the (Orders, Users) example [above](#how-we-generate-sql-based-on-different-join-types). Consider scenario where there are `orders` records of user ID = 5, but in the `users` model, no corresponding record of user ID 5 found. This is a **violation of the referential integrity rule** between orders and users (many-to-one relationship).
If we use INNER JOIN for the above query, the result set would eliminate orders rows with unfounded users. This is dangerous and will underreport the sales results.
Therefore, **using INNER JOIN does not resolve referential integrity violation correctly**.
```sql
-- IMPORTANT: This is not what Holistics does
-- Using INNER JOIN
SELECT
U.gender,
SUM(O.revenue) as total_orders
FROM orders O
INNER JOIN users U ON O.user_id = U.id
GROUP BY 1
```
Generally speaking, **Holistics will opt to use OUTER JOIN** when dealing with referential integrity issues. In some situation, this will add query performance overhead, but it ensures that all records will be accounted for and not fall into referential integrity violation traps.
### Opting into INNER JOIN with `nullable=false` {#nullable-relationships}
If you have verified that your data has no such violations, you can declare it by adding `nullable=false` to the relationship:
```aml
Dataset sales {
...
relationships: [
// Default (nullable=true): generates LEFT JOIN
relationship(sales_fact.store_id > stores.id, true),
// Non-nullable: generates INNER JOIN
relationship(sales_fact.date > date_dimension.date, true, nullable=false)
]
}
```
`nullable` is an assertion about your data, not a join instruction. Setting it to `false` declares that the joining column on the "many" side is never NULL and every value matches exactly one row in the target model. Under that assertion, INNER JOIN returns exactly the same results as LEFT JOIN (there are no unmatched rows to drop), so Holistics can safely generate the faster join type.
### Why INNER JOIN is faster
Data warehouses optimize INNER JOIN much more aggressively than LEFT JOIN. Filters on the dimension side can be pushed through the join into the fact table scan, partition pruning works normally (ClickHouse, for example, skips pruning when the filter sits on the right-hand side of a LEFT JOIN), and the join processes far fewer rows, which reduces memory pressure.
Take a query that sums sales, filtered by a date dimension field. With the default `nullable=true`:
```sql
SELECT SUM("sales_fact"."SalesAmount") AS "sales_amount"
FROM Sales_Fact "sales_fact"
LEFT JOIN Date_Dimension "date_dimension"
ON "sales_fact"."Date" = "date_dimension"."Date"
WHERE "date_dimension"."Date" >= '2026-02-01'
AND "date_dimension"."Date" < '2026-03-01'
```
With `nullable=false` on the relationship:
```sql
SELECT SUM("sales_fact"."SalesAmount") AS "sales_amount"
FROM Sales_Fact "sales_fact"
INNER JOIN Date_Dimension "date_dimension"
ON "sales_fact"."Date" = "date_dimension"."Date"
WHERE "date_dimension"."Date" >= '2026-02-01'
AND "date_dimension"."Date" < '2026-03-01'
```
Both queries return the same result when referential integrity holds. The INNER JOIN version, however, lets the warehouse prune partitions on the fact table and push the date filter down, which can turn a full-table scan into a small ranged read. On large, partitioned fact tables the difference is often an order of magnitude in both query time and memory usage.
### What happens when the assertion is wrong
If the data actually contains NULL or orphaned foreign keys, `nullable=false` makes the INNER JOIN drop those fact rows silently. There is no error or warning; totals simply shrink whenever the join is involved.
Consider this data, with `relationship(orders.product_id > products.id, true, nullable=false)`:
**orders**
| id | product_id | amount |
|----|------------|--------|
| 1 | P1 | 100 |
| 2 | P2 | 50 |
| 3 | NULL | 40 |
**products**
| id | name |
|----|------|
| P1 | Chair |
| P2 | Desk |
"Total amount" on its own needs no join and returns **190**. "Total amount by product name" requires the join, and the INNER JOIN drops order 3, returning **150**. The two reports disagree with no error anywhere. With the default `nullable=true`, the breakdown would instead show a NULL product group with amount 40, and both reports would agree at 190.
Holistics does not validate the assertion against your data, so keeping it truthful is the modeler's responsibility.
### When it is safe to use
Use `nullable=false` only when both of these hold:
1. The foreign-key column is `NOT NULL`, or you have verified there are no NULLs.
2. Every foreign-key value exists in the target model: an enforced foreign-key constraint, or a verified guarantee such as a date dimension covering all fact dates.
A quick check to run before enabling it:
```sql
SELECT COUNT(*)
FROM sales_fact f
LEFT JOIN date_dimension d ON f.date = d.date
WHERE d.date IS NULL;
-- Must return 0. Otherwise nullable=false will drop these rows.
```
---
## Path ambiguity in dataset
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Relationships in Dataset](/docs/datasets/dataset-relationships)
- [with_relationships](/reference/aql/with_relationships)
:::
## Introduction
When building datasets with multiple models, you may encounter situations where there are multiple possible join paths between two models. This is called path ambiguity, and understanding how Holistics handles it is crucial for building accurate analytics.
For example, let's say you have an e-commerce dataset with the following models and relationships:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [orders, users, order_items, countries, merchants, products, cities]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
relationship(cities.country_code > countries.code, true),
relationship(users.city_id > cities.id, true),
relationship(order_items.product_id > products.id, true),
relationship(products.merchant_id > merchants.id, true),
relationship(merchants.city_id > cities.id, true)
]
}
```
When you set up relationships like this, you'll see ambiguous path indicators in the dataset editor. **This indicator is there to raise awareness, not to block you**, so you can still use the dataset normally. Holistics will automatically resolve the ambiguity for you.

This documentation explains how that resolution works so you can understand which path Holistics chooses and why.
Let's look at a concrete example. Say you want to answer: **"What's the total order quantity by country?"** You'd drag in Country Name from `countries` and Total Order Quantity from `order_items`:
```aml
explore {
dimensions {
country_name: countries.name
}
measures {
total_order_quantity: sum(order_items.quantity)
}
}
```
Here's where ambiguity comes in. There are two valid ways to join from `countries` to `order_items`:
- **Path A:** countries → cities → **users → orders → order_items** (orders by customer country)
- **Path B:** countries → cities → **merchants → products → order_items** (orders by merchant country)
Each path gives you a different answer with a different business meaning: are you analyzing orders based on where your customers live, or where your merchants operate?
## How Holistics Resolves Ambiguity Automatically
When **multiple paths exist**, Holistics intelligently selects the most analytically correct path **using a ranking algorithm**. You can create datasets with all relationships active, and Holistics will automatically choose the right path at query time.
The algorithm works in 4 steps:
### Step 1: Path Tier Classification
All possible paths are categorized into 4 tiers based on common analytics patterns:
- **Tier 1 (Best):** Only **one-to-many** relationships (e.g., dimension → fact, like `countries → cities → users → orders`)
- **Tier 2:** Only **many-to-one** relationships (e.g., fact → dimension, like `orders → users → cities → countries`)
- **Tier 3:** Special sequential patterns (e.g., many-to-many using junction tables, like `users → orders → order_items → products`)
- **Tier 4:** Mixed patterns not matching the above
### Step 2: Assign Weights
Each relationship in a path has a **default weight of 0**. Weights increase when you explicitly specify relationships:
- **Default relationships** (defined in dataset): Weight = 0
- **[`with_relationships()`](/reference/aql/with_relationships) specified**: Higher weight than default
- **Nested `with_relationships()`**: Inner (nested) relationships get even higher weight
This means explicitly specified paths using `with_relationships()` take priority over default paths.
### Step 3: Rank Paths
Paths are compared in this order:
1. **Tier first** (Tier 1 > Tier 2 > Tier 3 > Tier 4)
2. **Weight second** (higher scores preferred)
3. **Path length third** (shorter paths preferred as they're simpler and more performant)
### Step 4: Automatic Selection with Transparency
- **Clear winner:** Holistics automatically uses the best-ranked path
- **Tie situation:** If multiple paths have identical rankings, Holistics will:
- Show a **warning** in the query preview
- Indicate which path was chosen
- Suggest resolving ambiguity manually
## Examples
### Example 1: Clear Winner Based on Tier Ranking
Query: "Total products by cities"
**Path 1:** `cities → merchants → products` **(3 hops)**
- **Business meaning:** Total products produced/sold by merchants in each city
- **Pattern:** cities → merchants → products
- **Tier:** Only one-to-many relationships → **Tier 1** (Best)
- **Length:** 3 hops
**Path 2:** `cities → users → orders → order_items → products` **(5 hops)**
- **Business meaning:** Total products bought by users living in each city
- **Pattern:** cities → users → orders → order_items (junction) → products
- **Tier:** Many-to-many using junction table (order_items) → **Tier 3**
- **Length:** 5 hops
#### Result: Holistics automatically selects Path 1

- **Tier 1 ranks higher than Tier 3** (tier is the primary ranking criteria)
- Path 1 has a simpler dimension → fact pattern
- Path 2 requires a junction table, making it more complex
Both paths are analytically correct but answer different business questions.
#### Overriding the automatic selection
If you need Path 2 (products bought by city residents) instead of Path 1 (products sold by city merchants), you can use [`with_relationships()`](/reference/aql/with_relationships) to control which path to use. We want the customer path instead of the merchant path, so we disable the direct merchant relationship:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
metric products_bought_by_city_residents {
label: 'Products Bought by City Residents'
type: 'number'
definition: @aql
products
| count(products.id)
| with_relationships(
//highlight-next-line
relationship(products.merchant_id > merchants.id, false, 'two_way')
)
;;
}
}
```
By disabling the `products → merchants` relationship, we're telling Holistics: "Don't take the direct path through merchants. Instead, use the longer path through order_items, orders, and users to reach cities."

### Example 2: Tie Situation with Same Tier, Weight, and Path Length
Query: "Total order items by countries"
**Path A:** `countries → cities → users → orders → order_items` **(5 hops)**
- **Business meaning:** Order items from customers in each country
- **Tier:** Only one-to-many relationships → **Tier 1**
- **Length:** 5 hops
**Path B:** `countries → cities → merchants → products → order_items` **(5 hops)**
- **Business meaning:** Order items from merchants located in each country
- **Tier:** Only one-to-many relationships → **Tier 1**
- **Length:** 5 hops
**Result:** Both paths have:
- Same tier (Tier 1 - both are valid dimension-to-fact patterns)
- Same length (5 hops)
- Same weights (both use default relationships)
When faced with a tie, **Holistics will automatically pick one of the paths** and may display a warning in the query preview, suggesting that you explicitly define which path to use for better clarity.

**Overriding the automatic selection:**
If you need a different path than the one Holistics selected, you can use [`with_relationships()`](/reference/aql/with_relationships) to explicitly control which path to use. In this example, we want the merchant path instead of the customer path, so we disable the relationship that leads through customers:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
metric orders_by_merchant_countries {
label: 'Total Orders by Merchant Countries'
type: 'number'
definition: @aql
order_items
| count(order_items.id)
| with_relationships(
//highlight-next-line
relationship(order_items.order_id > orders.id, false, 'two_way')
)
;;
}
}
```
By disabling the `order_items → orders` relationship, we're telling Holistics: "Don't go through orders to reach countries. Instead, use the alternative path through products and merchants."

## When Automatic Resolution Cannot Work
While Holistics' automatic path resolution handles most ambiguous path scenarios, there are specific cases where the algorithm cannot determine the correct path automatically. In these situations, you must manually configure your relationships.
### Role-Playing Dimensions
**Role-playing dimensions** occur when the same dimension table is referenced multiple times from a single fact table, each time with a different meaning. The classic example is using a date dimension for multiple date fields in an order fact: created date, delivered date, cancelled date, etc.
**Why automatic resolution fails:** When you have multiple relationships between the same two models (e.g., `fct_orders` → `dim_dates` via created_at, delivered_at, cancelled_at), there is no clear "winner" based on tier, weight, or path length - all paths have identical characteristics.
**Solution:** You must disable all relationships except one (the most commonly used), and use [`with_relationships()`](/reference/aql/with_relationships) to explicitly activate the other relationships when needed.
For detailed implementation steps and an alternative approach using `extend()`, see [Role-Playing Dimensions Pattern](/docs/modeling/modeling-patterns/role-playing-dimensions).
## Manual Control Options
While automatic resolution works well for most cases, you have full control to override the behavior:
### Option 1: Disable Relationships and Use `with_relationships()`
Use [`with_relationships()`](/reference/aql/with_relationships) to explicitly specify which path a metric should use. This is ideal when different metrics need different paths.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
relationships: [
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(users.city_id > cities.id, true),
relationship(cities.country_code > countries.code, true),
relationship(products.merchant_id > merchants.id, true),
//highlight-next-line
relationship(merchants.city_id > cities.id, false) // The relationship is currently disabled
]
metric order_values_by_merchant_countries {
label: 'Total Order Values by Merchant Countries'
type: 'number'
definition: @aql orders
| count(orders.id)
//highlight-next-line
| with_relationships(merchants.city_id > cities.id);;
;;
}
}
```
**When to use:**
- You want to completely prevent a certain path from being used across the entire dataset
- You're working with legacy datasets and want to maintain existing behavior
**Trade-offs:**
- ❌ Disables the path for ALL queries in the dataset
- ❌ Requires `with_relationships()` to re-enable for specific metrics
### Option 2: Duplicate Models
Create separate dimension models for different contexts (e.g., `user_cities` and `merchant_cities`):
**When to use:**
- Clear semantic separation is important for end users
- You want to avoid any ambiguity warnings
**Trade-offs:**
- ❌ Makes the dataset larger and more complex to maintain
- ❌ Duplicates dimension data
## Best Practices
1. **Let Holistics handle it:** For most cases, the automatic resolution works correctly. Trust the algorithm!
2. **Use `with_relationships()` for exceptions:** When you need a specific path that differs from the automatic selection, explicitly declare it.
3. **Monitor warnings:** If you see ambiguity warnings in query previews, review whether the automatic selection makes sense for your use case.
4. **Document complex paths:** Add comments in your AML code explaining why specific paths are chosen for metrics.
5. **Test your metrics:** Always verify that metrics return expected results, especially in datasets with multiple possible paths.
---
## Why relationships, not joins
## Introduction
In raw SQL, every analytical question commits you to a specific `FROM` table and a specific JOIN sequence. Change the question, rewrite the SQL. Add a model, edit every query that touches it. Holistics breaks that coupling: you declare how tables relate once on a dataset, and the SQL gets derived per query.
This page is for analysts and engineers used to writing SQL by hand, or to tools where joins are declared inside an explore (Looker, dbt-style metric layers).
## Joins vs relationships
A JOIN is an instruction to combine two tables: this one with that one, on this condition. A relationship records that two tables are connected (which keys link them, and at what cardinality) without saying how to combine them. People conflate the two often, and the conflation usually shows up later, when a question changes shape and the existing JOINs don't fit it.
### Joins
A JOIN can be written inline in a query, or declared in a model file (for e.g., Looker). Either way, a JOIN is an *instruction to combine* tables. It commits to a root, a join type (LEFT, INNER, OUTER, etc.), and a condition. The JOIN sits inside a specific access path: from this table, join those tables, this way.
```sql
-- Inline JOIN (raw SQL): written per query
FROM orders LEFT JOIN users ON orders.user_id = users.id
```
```lookml
# Declared JOIN (Looker-style): part of an explore, committed to a root and a join type
explore: orders {
join: users {
type: left_outer
sql_on: ${orders.user_id} = ${users.id} ;;
relationship: many_to_one
}
}
```
When a user picks fields via the explore's field picker (say `orders.amount` and `users.name`), Looker compiles the declaration plus the selection into SQL:
```sql
-- Compiled output: structurally identical to writing the JOIN inline
SELECT orders.amount, users.name
FROM orders LEFT JOIN users ON orders.user_id = users.id
```
The declared JOIN becomes an inline JOIN at query time. Same kind of object, written once and reused, but still committed to the same root and the same join type as if you'd hand-written it.
### Relationships
A relationship is a different object. It records how two tables connect: which keys link them, and with what cardinality (one user has many orders, for example). It doesn't commit to a root or a join type. It just records the connection and stays out of the way of any specific access path.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
models: [orders, users, cities, countries]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(users.city_id > cities.id, true),
relationship(cities.country_code > countries.code, true)
]
}
```
Holistics holds your relationships as a graph and picks the access path per query: which root, which join type, which traversal. The same relationship gets reused across queries that traverse it in different ways.
## Why Holistics declares relationships instead of joins
A JOIN commits you to a root model: the table right after `FROM`. With `FROM orders` as your root, you can answer questions per order; for questions per user, you need a different query rooted at `FROM users`. Each new root means a new dataset or explore to build and maintain. A relationship doesn't commit to a root, so one declaration serves any question that traverses it.
In raw SQL, the JOIN sequence and the question are written together. Every query commits to both: a specific `FROM` table as root, and a specific path of joins to reach the dimensions and metrics you want. The choice of root decides which rows make it into the final result.
```sql
select ...
from table_a -- table_a is considered as root
left join ...
```
This matters because **the right root depends on the question, not the schema**. Take the same four models (`orders`, `users`, `cities`, `countries`) and ask three different questions:
```sql
-- Total revenue by country
SELECT countries.name, SUM(orders.amount) AS revenue
FROM orders -- orders is root model
LEFT JOIN users ON orders.user_id = users.id
LEFT JOIN cities ON users.city_id = cities.id
LEFT JOIN countries ON cities.country_code = countries.code
GROUP BY 1;
-- How many users live in each city
SELECT cities.name, COUNT(users.id) AS user_count
FROM cities -- cities is root model
LEFT JOIN users ON users.city_id = cities.id
GROUP BY 1
ORDER BY 2 DESC;
-- Total orders per user, including users who haven't bought anything yet
SELECT users.name, COUNT(orders.id) AS order_count
FROM users -- users is root model
LEFT JOIN orders ON orders.user_id = users.id
GROUP BY 1
ORDER BY 2 DESC;
```
Same models, three roots. Every new question is new SQL written and maintained, and every schema change ripples across every query that touches the affected tables.
Holistics splits connection from question. The connection is a property of the dataset (declared once, as a graph of `>` relationships between models). The question is a property of the query (the fields you select). Holistics derives the JOINs each time, picking the root per query based on which models the dimensions, metrics, and filters touch. There's no fixed root, no pre-committed dataset.
### One dataset, many questions
Take those four models (`orders`, `users`, `cities`, `countries`) connected by relationships. Three different business questions, three different SQL "starting points" needed:
| Question | What you'd ask in Holistics | Generated `FROM` |
|---|---|---|
| Total orders per country | dim: `countries.name`, metric: `count(orders.id)` | `FROM orders` |
| Total users per country | dim: `countries.name`, metric: `count(users.id)` | `FROM users` |
| Total cities per country | dim: `countries.name`, metric: `count(cities.id)` | `FROM cities` |
In a join-based tool, those three questions need three queries, or three datasets if your tool fixes the FROM table at definition time. The SQL is structurally different for each.
In Holistics, all three metrics live in the same dataset and can sit on the same chart, table, or filter without any of that. The relationships graph stays the same; the root shifts based on which model your metric points at.
Concretely, with `countries.name` as the dimension and all three metrics on the same chart, Holistics emits one CTE per metric (each rooted at the model the metric counts) and FULL JOINs them on the shared dimension:
```sql
WITH orders_by_country AS (
SELECT countries.name, COUNT(orders.id) AS total_orders
FROM orders
LEFT JOIN users ON orders.user_id = users.id
LEFT JOIN cities ON users.city_id = cities.id
LEFT JOIN countries ON cities.country_code = countries.code
GROUP BY 1
),
users_by_country AS (
SELECT countries.name, COUNT(users.id) AS total_users
FROM users
LEFT JOIN cities ON users.city_id = cities.id
LEFT JOIN countries ON cities.country_code = countries.code
GROUP BY 1
),
cities_by_country AS (
SELECT countries.name, COUNT(cities.id) AS total_cities
FROM cities
LEFT JOIN countries ON cities.country_code = countries.code
GROUP BY 1
)
SELECT
COALESCE(o.name, u.name, c.name) AS country,
o.total_orders,
u.total_users,
c.total_cities
FROM orders_by_country o
FULL JOIN users_by_country u ON o.name = u.name
FULL JOIN cities_by_country c ON COALESCE(o.name, u.name) = c.name
```
Notice the three `FROM` clauses: `orders`, `users`, `cities`. Each metric gets its own root, picked automatically from the model it counts. You wrote no SQL for this.
For more details on how Holistics generates these joins (LEFT vs FULL OUTER per cardinality, fan-out auto-resolution, referential integrity), see [how Holistics handles joins](/docs/joins/how-joins-work).
### Adding a model doesn't cascade
Production datasets grow. New entities, new tables, new business units land in your model regularly, and the cost of incorporating each addition determines how well your modeling layer keeps up over time.
Take the ecommerce dataset and add `merchants` to it. Suppose you also need `products.merchant_id` and `merchants.city_id` so merchants can be filtered by city.
In a join-based tool, that addition cascades:
- Add the `merchants` table to your data model, and edit every existing query that joins `products` and should now surface merchant data to bring in the new join.
- For each new merchant-anchored question ("revenue per merchant", "merchants per city", "products per merchant"), build a new query rooted at the right starting table. Repeat for every downstream question that combines merchants with the rest of your model.
In Holistics, the same addition is two lines:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
models: [orders, order_items, users, products, cities, countries, merchants]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(users.city_id > cities.id, true),
relationship(cities.country_code > countries.code, true),
//highlight-next-line
relationship(products.merchant_id > merchants.id, true),
//highlight-next-line
relationship(merchants.city_id > cities.id, true)
]
}
```
Every existing query still works. Questions involving merchants ("revenue by merchant", "products per merchant city", "merchants per country") become askable from the same dataset, with no new SQL written. Holistics traverses the bigger graph the same way it traversed the smaller one.
### Built-in fan-out resolution
Cardinality is recorded on every relationship. That tells Holistics when joining a "one"-side aggregate against a "many"-side dimension would otherwise inflate the result. Instead of joining and then aggregating, Holistics first reduces the "many" side to distinct (key, dimension) pairs, then joins back.
For example, ask the ecommerce dataset for `count(users.id)` grouped by `orders.status`. The path is `users → orders` (one user has many orders), so a naive join would count each user once per order they placed; a user with five cancelled orders would inflate the cancelled count by five. Holistics first reduces orders to distinct `(user_id, status)` pairs, then joins back to users:
```sql
WITH user_status_pairs AS (
SELECT user_id, status
FROM orders
GROUP BY 1, 2
)
SELECT
s.status,
COUNT(users.id) AS user_count
FROM users
LEFT JOIN user_status_pairs s ON users.id = s.user_id
GROUP BY 1
```
Each user contributes one row per status they have orders in, not one per order. For the mechanism, see [how Holistics handles joins](/docs/joins/how-joins-work).
Join-based tools solve the same overcounting under different names. Looker, for example, calls it [symmetric aggregates](https://docs.cloud.google.com/looker/docs/best-practices/understanding-symmetric-aggregates). With relationships, the resolution falls out of cardinality you've already declared, with no extra metadata or per-query setup.
## The mental shift: think about the question, not the join recipe
If you're SQL-fluent, your first reflex when asked "what's revenue by country?" is probably to start sketching: `FROM orders LEFT JOIN users LEFT JOIN cities LEFT JOIN countries`. That instinct isn't wrong. Holistics just asks you to start one step earlier in the thought process.
The shift is to start with the *question* and what it needs to be answered:
- What am I measuring? The metric (total orders, revenue, registered users, users who placed an order).
- What am I grouping by? The dimensions (country, month, product category).
- What grain do I want? The row level (one row per country? per user per month?).
Once you can answer those, the JOIN is just the SQL Holistics emits to fulfill the question.
The same questions drive the model layer too. "What am I measuring?" maps to a fact model; "what am I grouping by?" maps to a dimension model. The relationships you declare between them are how those answers stay durable: build the graph once, and every variant of the question becomes askable without new SQL. For the common shapes (star, galaxy, snowflake, role-playing dimensions), see [modeling patterns](/docs/modeling-patterns).
## Handling path ambiguity
As the relationship graph grows, the same pair of models can end up connected by more than one path. In the ecommerce dataset extended with merchants, there are two valid paths from `countries` to `order_items`:
- `countries → cities → users → orders → order_items` (orders by customer country)
- `countries → cities → merchants → products → order_items` (orders by merchant country)
These are not the same question. Customer country and merchant country are different facts about the same row.
Holistics resolves this with a ranking algorithm: tier first (one-to-many is preferred over many-to-many through junctions), then explicit overrides via `with_relationships()`, then path length. When there's a clear winner, Holistics uses it silently. When multiple paths tie on all three criteria, Holistics picks one and shows a warning so you can make the call explicit.
In practice, most ambiguities resolve silently. Holistics picks the shorter, higher-tier path, which usually matches what you'd have written by hand. The warning only fires when there's a genuine tie.
In a join-based tool, ambiguity doesn't arise the same way: you wrote the JOIN sequence, so you've already chosen the path. That's a real upside (no ambiguity warnings, no tie-breaking), but it's the same property that makes new questions costly. You pay the explicit-path cost on every query in exchange for never being surprised.
For the full ranking algorithm and override mechanics, see [Path ambiguity in dataset](/docs/joins/path-ambiguity).
## When you want explicit control
Relationships are the default for a reason: they handle most analytics questions concisely, without you writing SQL per question. But sometimes you need precise control over how a specific query gets joined, or you need to do something relationships can't express at all. Holistics gives you those tools without making you give up the relationship-based design.
### `with_relationships()` for per-metric path control
When a single metric needs a non-default join path, wrap it with `with_relationships()`. This is useful for:
- Forcing a specific path through an ambiguous graph: "Products bought by city residents" might need to traverse `cities → users → orders → order_items → products` instead of the default direct `cities → merchants → products`.
- Role-playing dimensions: when `fct_orders` has multiple date fields (`created_at`, `delivered_at`, `cancelled_at`) all referencing `dim_dates`, you mark only one as the active relationship and use `with_relationships()` to switch to the others per metric. See [Role-playing dimensions](/docs/modeling/modeling-patterns/role-playing-dimensions) for the full pattern.
Here, the metric counts products but forces traversal through `merchants`, regardless of the dataset's default path:
```aml
metric products_bought_by_city_residents {
definition: @aql
products
| count(products.id)
| with_relationships(
relationship(products.merchant_id > merchants.id, false, 'two_way')
)
;;
}
```
Reference: [`with_relationships()`](/reference/aql/with_relationships).
### `nullable=false` for verified referential integrity
Holistics defaults to LEFT JOIN because it preserves every fact row even when a foreign key is NULL or has no match. That safety has a cost: warehouses optimize INNER JOIN far more aggressively (join pushdown, partition pruning, lower memory).
When you have verified that a relationship's keys always match, you can record that fact on the relationship, and Holistics will generate an INNER JOIN for it:
```aml
relationship(sales_fact.date > date_dimension.date, true, nullable=false)
```
This stays true to the relationship philosophy: you're stating a property of the data (referential integrity holds), not writing a join instruction, and the engine derives the join type from it. If the assertion is wrong, though, the INNER JOIN silently drops unmatched fact rows. See [how Holistics handles joins](/docs/joins/how-joins-work#nullable-relationships) for the failure mode and a verification query.
### Query models when relationships aren't enough
Some questions cannot be expressed through relationships alone. The relationship grammar is equi-join only (`modelA.field = modelB.field`), which rules out:
- **Non-equi joins.** Range or inequality predicates the relationship grammar can't express. The classic case is matching a fact to the SCD Type 2 dimension version that was active when the event happened: `fct.event_date BETWEEN dim_history.valid_from AND dim_history.valid_to`. Sessionization (`events.ts BETWEEN sessions.start AND sessions.end`) and range bucketing land here too.
- **UNIONs across unrelated tables.** Combining rows from sources that aren't connected by foreign keys (e.g., merging two event streams into one analytical model).
Sometimes you just need to write SQL.
For these cases, build a **query model**. A query model is a model whose source is a SQL query, not a database table. It plugs into your dataset like any other model: you can define dimensions and metrics on it, relate it to other models, and query it through the same UI.
But query models come with a real cost. The SQL inside is static, which means the flexibility relationships give you (dynamic root model, grain control, automatic path resolution across questions) doesn't apply within that query model. Every new question that needs the SQL shaped differently becomes a new query model.
Treat them as a deliberate fallback, not a default. Use a query model when relationships genuinely can't express the problem; otherwise, prefer relationships even if the result feels less explicit.
Reference: [Query models](/docs/query-models).
## Where to go next
**Foundations:**
- [Build relationships](/docs/relationships)
- [Relationships in dataset](/docs/datasets/dataset-relationships)
- [How Holistics handles joins](/docs/joins/how-joins-work)
**Patterns and edge cases:**
- [Modeling patterns](/docs/modeling-patterns)
- [Path ambiguity in dataset](/docs/joins/path-ambiguity)
- [Show all dimension values (including empty)](/docs/show-all-dim-values)
- [Role-playing dimensions](/docs/modeling/modeling-patterns/role-playing-dimensions)
- [Fan-out troubleshooting](/docs/joins/troubleshooting-fanout)
**References and escape hatches:**
- [`with_relationships()`](/reference/aql/with_relationships)
- [Non-nullable relationships (INNER JOIN)](/docs/joins/how-joins-work#nullable-relationships)
- [Query models](/docs/query-models)
---
## Cannot combine fields due to fan-out issues?
In some cases, when exploring you encounter this error `Cannot combine selected fields due to potential fan-out issues.`
## Why does fan-out issue happen in real-life?
Fan-out happens when you **LEFT JOIN** two data models with **one-to-many** relationship, that one row of your model on the left can match up with multiple rows in your joined table on the right. So, aggregate functions like COUNT or SUM may include duplicates, throwing off the results.
*For example*, you are working on an ecommerce dataset that contains two data models `users` and `orders` with **one-to-many** relationship as below diagram.
Let's say when exploring this set of Data, if you use any field from `orders` with a measure from `users`, the fan-out issue occurs. For example, you want to know total users have activities (`orders.status` is not null) on your ecommerce platform.
In this case, you use measure field `users.total_users` (`COUNT(users."id")`) of `users` data model and non-measure field `orders.status` of `orders` data model. Since the relationship between `users` and `orders` is **one-to-many**, we say that a *fanout* has occurred. The measure `users.total_users` (`COUNT(users."id")`) will be duplicated and normally count of the user will be wrong.
The underlying query will be generated as below:
```sql
SELECT
T1."user_id" AS "user_id",
COUNT(T0."id") AS "total_user"
FROM
"ecommerce"."users" T0
LEFT JOIN "ecommerce"."order" T1 ON T0."id" = T1."user_id"
WHERE
T0."id" < 2200 and T0."status" is not null
GROUP BY 1
ORDER BY 2 DESC
LIMIT 500
```
## What have we done to solve the issue
:::info Note:
Holistics would auto-resolve this fan-out issue for the Aggregations and Business Calculation in our Dataset exploration UI or measure defined using AML (only in 4.0). However, we do not support solving the fan-out problems when using measures created in the 3.0 Data Model layer.
:::
In order to solve this issue, Holistics has modified the underlying query to get the distinct value when the fan-out issue happens.
Specifically, when the measure/aggregation is on the dimension model instead of the fact model, we will calculate the measure first inside the dimension model before executing the join.
For example, let take the example above, when we count the `user.id`, this query will run first inside `user` model to calculate total `user` for each `user_id`. Let's call this Set A
```sql
SELECT
T0."id" AS "user_id",
COUNT(T0."id") AS "total_user"
FROM
"ecommerce"."users" T0
WHERE
T0."id" < 2200
GROUP BY 1
ORDER BY 2 DESC
```
After that, we will execute the join from `orders` to `users` as normal and select distinct `user_id` from `order` model. Let's call this Set B
```sql
SELECT
distinct T0."id" AS "user_id"
FROM
"ecommerce"."users" T0
LEFT JOIN "ecommerce"."order" T1 ON T0."id" = T1."user_id"
WHERE
T0."id" < 2200 AND T1."status" is not null
```
Finally, we will execute Inner Join Set A and Set B, select `user_id` from Set B and `total_user` from Set A. By doing this, the `user_id` will be unique and fan-out issue no longer exists.
The final query will be:
```sql
WITH total_actived_users AS (
SELECT
T0."id" AS "user_id",
COUNT(T0."id") AS "total_users "
FROM
"ecommerce"."users" T0
WHERE
T0."id" < 2200
GROUP BY 1
ORDER BY 2 DESC
)
, distinct_user_id AS (
SELECT
DISTINCT T0."id" AS "user_id"
FROM
"ecommerce"."users" T0
LEFT JOIN
"ecommerce"."orders" T1 ON T0."id" = T1."user_id"
WHERE
T0."id" < 2200 AND T1."status" IS NOT NULL
)
SELECT
T1."user_id",
T0."total_users"
FROM
total_actived_users T0
INNER JOIN
distinct_user_id T1 ON T0."user_id" = T1."user_id"
```
## Why do you still encounter this fan-out issue? How to solve?
In Holistics, the fan-out issue happens **when your custom measure is defined by your own SQL logic**, and (for now) we're unable to analyze the SQL semantic (of your measure) to handle the fan-out issue.
If you use either **Aggregation Function** or **Business Calculation** in our Dataset explore or **defining measure using AML** (only in 4.0), the issue will be solved.
Let's deep dive into our solutions to protect yourself from the fan-out issues.
### Use aggregation or business calculation on the dataset exploration UI
- If your measure is only a **basic calculation** (COUNT, SUM, AVG,...), you can use our **Aggregation in our dataset exploration** instead of creating a custom measure in the modeling layer.
Just drag whatever field was originally nested in your custom measure definition, and place that field in the y-axis field. Holistics will naturally assign an aggregation to that field, most likely a `count`. You can then proceed to click on the drop-down icon on the right side of the field box to select a different aggregation.
- For **composite measure**, we advise that you use **Business calculation.** For example, when you need to divide one measure by another measure or need to use `case...when`
```sql
safe_divide(
sum(
case(
when: public_appointments.status == 'Quote Requested', then: 0,
when: public_appointments.status == 'Incomplete', then: 0,
else: 1
)
),
sum(
case(
when: public_appointments.status == 'Incomplete', then: 0,
else: 1
)
)
)
```
### Writing the measure using AML syntax (only in 4.0)
If writing the [measure using AML](/reference/aml/field#measure), we have a mechanism to cope with fan-out.
By using our aggregation_type (`'count' | 'count distinct' | 'sum' | 'avg' | 'max' | 'min'`), we can easily detect the aggregation type of a field and prevent fanout issues.
In case you want to use composite measure, as a workaround, you can use field **@aml**
instead of **@sql**. Please refer to the example below with the use case in that you would like to divide one measure by another measure:
However, do note that we have not supported `aml` field in the UI mode so, at the moment, you should not edit the `measure a_b` in the UI mode.
---
## Key concepts
This page defines the frequently used terms and concepts in Holistics, organized by where they sit in the architecture. They map to the structural pieces described in [Why Holistics](/docs/difference): the expressive semantic layer where business logic lives, and the analytics-as-code infrastructure that keeps it durable.
## Source: your data warehouse
The system of record. Holistics is a thin client on top of your warehouse; your raw data never leaves your system.
| Concept | Definition |
|---|---|
| [**Data Source**](/docs/connect) | A connection to your SQL database, typically a data warehouse like Snowflake, BigQuery, Redshift, or Databricks. Holistics queries it directly; nothing is copied or stored on our side. |
## Modeling: the semantic layer
Where business logic lives as code. Models, datasets, metrics, and relationships are defined declaratively and queried compositionally.
| Concept | Definition |
|---|---|
| [**Semantic Layer**](/docs/modeling) | The governed layer where business logic lives. Models, metrics, dimensions, datasets, and relationships are defined here as composable code objects. AI, dashboards, drag-and-drop exploration, and embedded analytics all reason from it. |
| [**Data Model**](/docs/data-model) | An abstract object that sits on top of a database table or query, where business logic is added. Types: Table, Query, Import. |
| [**Relationship**](/docs/relationships) | The link between data models. Similar to joins or foreign-key relationships in a database, but defined declaratively at the model level. |
| [**Dimension**](/docs/model-fields) | A non-aggregate field that references an underlying column or is derived using non-aggregate functions. |
| [**Measure**](/docs/model-fields#measures) | An aggregating field created with aggregate functions (SUM, COUNT, etc.). Defined inside a model. |
| [**Dataset**](/docs/datasets) | A curated collection of data models and their relationships. Datasets are the unit of self-service; business users explore datasets, and dashboards and charts build on top of them. |
| [**Metric**](/as-code/aql/learn/what-aql-is-for) | A full aggregation query written in AQL: composable, reusable, and independent of any single dataset or visualization. Metrics are first-class objects you can combine, transform, and reuse. |
| [**AMQL**](/as-code/amql/) | The analytics-as-code language behind Holistics' expressive semantic layer. Two parts: AML for modeling the semantic layer; AQL for querying it. |
| [**AML**](/reference/aml/) | Analytics Modeling Language. The typed declarative language the semantic layer itself is written in: models, datasets, dashboards, and relationships. AML is what makes the semantic layer **programmable** (vs YAML configs). |
| [**AQL**](/as-code/aql/) | Analytics Query Language. The composable query language layered on top of the AML-defined semantic layer. Metrics are first-class objects, not SQL strings. Compiles deterministically to SQL. |
:::info
**Metric vs. measure.** In the BI world, "metric" and "measure" are often used interchangeably. In Holistics specifically, a [measure is defined in a model](/docs/model-fields#measures), while a [metric is defined at the dataset level](/docs/metrics-in-datasets) and written in AQL.
:::
## Execution: compile and run
How questions become answers. AML and AQL compile to native SQL, executed against your warehouse.
| Concept | Definition |
|---|---|
| **Query Engine** | The compiler and executor. AQL and AML compile deterministically to warehouse-native SQL. Every query is pushed down to your warehouse, so the warehouse does the heavy lifting. |
| [**Inspectable Compiled SQL**](/as-code/aql/) | The SQL that AQL compiles to is visible. Useful for debugging, performance tuning, and building trust in AI-generated queries. |
## Consumption: output surfaces
How people and AI consume the same governed metrics.
| Concept | Definition |
|---|---|
| [**Data Exploration**](/docs/data-exploration) | The interface for exploring and visualizing data: drag-and-drop, SQL editor, or natural-language conversation with AI. All three reason from the same governed semantic layer. |
| [**Dashboard**](/docs/dashboards/) | A collection of charts and content blocks that present data to business users. Supports [filters](/docs/filters/index.md), drill-throughs, and exploration. Dashboards themselves are also code, so they're version-controllable. |
| [**Holistics AI**](/docs/ai) | Natural-language AI that operates on top of the governed semantic layer. Generates [AQL](/as-code/aql/) against your governed metric definitions instead of raw SQL against schema, which is why answers stay reliable across follow-up questions. See [Why Holistics AI is reliable](/docs/ai/architecture). |
| [**Embedded Analytics**](/embedded/) | Embed dashboards, self-serve, and AI inside your product. The same governed semantic layer powers customer-facing analytics. |
## Foundation: analytics-as-code
The cross-cutting substrate. Everything above (models, datasets, metrics, dashboards, permissions) is code, governed by the same engineering practices used for production software.
| Concept | Definition |
|---|---|
| [**Analytics-as-Code**](/docs/analytics-as-code/) | Every definition is stored as code. This makes the semantic layer **durable rather than mutable**: business logic gets history, review, branches, environments, inspectable compiled output, and rollback. |
| [**Git Integration**](/docs/git-version-control) | Your Holistics code base is powered by Git: branches, pull requests, code review, history, rollback. Use Holistics' built-in repository or connect your own external Git repo. |
| [**Environments**](/docs/development/dev-prod-mode) | Develop in dev, validate in staging, deploy to prod through a real promotion workflow. Multi-environment setups, dynamic data sources, and dynamic schemas keep production stable while you iterate. |
---
## Annex 4: EU Standard Contractual Clauses
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/dpa).
:::
**Last updated**: 12 June 2026
**Note:** If there are actual differences between the official EU SCCs ([Commission Implementing Decision (EU) 2021/914](https://eur-lex.europa.eu/eli/dec_impl/2021/914/oj)) and this version below, the official EU SCCs prevail.
---
### Which Module applies
These Clauses incorporate both Module 2 (Controller to Processor) and Module 3 (Processor to Processor) of the EU SCCs. The applicable Module is determined by Customer's role with respect to the relevant Customer Database, and applies automatically by operation of this clause (no separate election by either party is required):
- **Module 2 (Controller to Processor)** applies where, and to the extent that, Customer acts as a controller of the personal data in the Customer Database.
- **Module 3 (Processor to Processor)** applies where, and to the extent that, Customer acts as a processor processing that personal data on behalf of one or more third-party controllers.
Where Customer's role is mixed (for example, controller for some personal data and processor for other personal data, as is common in embedded analytics deployments), each Module applies to the corresponding processing. Where Customer's role is not specified, both Modules apply to the extent each is relevant. This determination follows the actual roles of the parties under European Data Protection Law.
Throughout these Clauses, **Holistics is the data importer** and **Customer is the data exporter**. Under Module 3, references to "the controller" mean the third-party controller(s) on whose behalf Customer (as data exporter) processes the personal data.
---
STANDARD CONTRACTUAL CLAUSES
Module 2: Controller to Processor · Module 3: Processor to Processor
**SECTION I**
***Clause 1***
**Purpose and scope**
(a) The purpose of these standard contractual clauses is to ensure compliance with the requirements of Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation) ([^1]) for the transfer of personal data to a third country.
(b) The Parties:
(i) the natural or legal person(s), public authority/ies, agency/ies or other body/ies (hereinafter 'entity/ies') transferring the personal data, as listed in Annex I.A (hereinafter each 'data exporter'), and
(ii) the entity/ies in a third country receiving the personal data from the data exporter, directly or indirectly via another entity also Party to these Clauses, as listed in Annex I.A (hereinafter each 'data importer')
have agreed to these standard contractual clauses (hereinafter: 'Clauses').
(c) These Clauses apply with respect to the transfer of personal data as specified in Annex I.B.
(d) The Appendix to these Clauses containing the Annexes referred to therein forms an integral part of these Clauses.
***Clause 2***
**Effect and invariability of the Clauses**
(a) These Clauses set out appropriate safeguards, including enforceable data subject rights and effective legal remedies, pursuant to Article 46(1) and Article 46(2)(c) of Regulation (EU) 2016/679 and, with respect to data transfers from controllers to processors and/or processors to processors, standard contractual clauses pursuant to Article 28(7) of Regulation (EU) 2016/679, provided they are not modified, except to select the appropriate Module(s) or to add or update information in the Appendix. This does not prevent the Parties from including the standard contractual clauses laid down in these Clauses in a wider contract and/or to add other clauses or additional safeguards, provided that they do not contradict, directly or indirectly, these Clauses or prejudice the fundamental rights or freedoms of data subjects.
(b) These Clauses are without prejudice to obligations to which the data exporter is subject by virtue of Regulation (EU) 2016/679.
***Clause 3***
**Third-party beneficiaries**
(a) Data subjects may invoke and enforce these Clauses, as third-party beneficiaries, against the data exporter and/or data importer, with the following exceptions:
(i) Clause 1, Clause 2, Clause 3, Clause 6, Clause 7;
(ii)
> **MODULE TWO (Controller to Processor):** Clause 8.1(b), 8.9(a), (c), (d) and (e);
>
> **MODULE THREE (Processor to Processor):** Clause 8.1(a), (c) and (d) and Clause 8.9(a), (c), (d), (e), (f) and (g);
(iii) Clause 9(a), (c), (d) and (e);
(iv) Clause 12(a), (d) and (f);
(v) Clause 13;
(vi) Clause 15.1(c), (d) and (e);
(vii) Clause 16(e);
(viii) Clause 18(a) and (b).
(b) Paragraph (a) is without prejudice to rights of data subjects under Regulation (EU) 2016/679.
***Clause 4***
**Interpretation**
(a) Where these Clauses use terms that are defined in Regulation (EU) 2016/679, those terms shall have the same meaning as in that Regulation.
(b) These Clauses shall be read and interpreted in the light of the provisions of Regulation (EU) 2016/679.
(c) These Clauses shall not be interpreted in a way that conflicts with rights and obligations provided for in Regulation (EU) 2016/679.
***Clause 5***
**Hierarchy**
In the event of a contradiction between these Clauses and the provisions of related agreements between the Parties, existing at the time these Clauses are agreed or entered into thereafter, these Clauses shall prevail.
***Clause 6***
**Description of the transfer(s)**
The details of the transfer(s), and in particular the categories of personal data that are transferred and the purpose(s) for which they are transferred, are specified in Annex I.B.
***Clause 7 – Optional***
**Docking clause**
(a) An entity that is not a Party to these Clauses may, with the agreement of the Parties, accede to these Clauses at any time, either as a data exporter or as a data importer, by completing the Appendix and signing Annex I.A.
(b) Once it has completed the Appendix and signed Annex I.A, the acceding entity shall become a Party to these Clauses and have the rights and obligations of a data exporter or data importer in accordance with its designation in Annex I.A.
(c) The acceding entity shall have no rights or obligations arising under these Clauses from the period prior to becoming a Party.
**SECTION II – OBLIGATIONS OF THE PARTIES**
***Clause 8***
**Data protection safeguards**
The data exporter warrants that it has used reasonable efforts to determine that the data importer is able, through the implementation of appropriate technical and organisational measures, to satisfy its obligations under these Clauses.
**8.1 Instructions**
> **MODULE TWO (Controller to Processor):**
>
> (a) The data importer shall process the personal data only on documented instructions from the data exporter. The data exporter may give such instructions throughout the duration of the contract.
>
> (b) The data importer shall immediately inform the data exporter if it is unable to follow those instructions.
> **MODULE THREE (Processor to Processor):**
>
> (a) The data exporter has informed the data importer that it acts as processor under the instructions of its controller(s), which the data exporter shall make available to the data importer prior to processing.
>
> (b) The data importer shall process the personal data only on documented instructions from the controller, as communicated to the data importer by the data exporter, and any additional documented instructions from the data exporter. Such additional instructions shall not conflict with the instructions from the controller. The controller or data exporter may give further documented instructions regarding the data processing throughout the duration of the contract.
>
> (c) The data importer shall immediately inform the data exporter if it is unable to follow those instructions. Where the data importer is unable to follow the instructions from the controller, the data exporter shall immediately notify the controller.
>
> (d) The data exporter warrants that it has imposed the same data protection obligations on the data importer as set out in the contract or other legal act under Union or Member State law between the controller and the data exporter ([^2]).
**8.2 Purpose limitation**
The data importer shall process the personal data only for the specific purpose(s) of the transfer, as set out in Annex I.B, unless on further instructions:
> **MODULE TWO (Controller to Processor):** from the data exporter.
>
> **MODULE THREE (Processor to Processor):** from the controller, as communicated to the data importer by the data exporter, or from the data exporter.
**8.3 Transparency**
On request, the data exporter shall make a copy of these Clauses, including the Appendix as completed by the Parties, available to the data subject free of charge. To the extent necessary to protect business secrets or other confidential information, including personal data, the data exporter may redact part of the text of the Appendix to these Clauses prior to sharing a copy, but shall provide a meaningful summary where the data subject would otherwise not be able to understand its content or exercise his/her rights. On request, the Parties shall provide the data subject with the reasons for the redactions, to the extent possible without revealing the redacted information.
> **MODULE TWO (Controller to Processor):** This Clause is without prejudice to the obligations of the data exporter under Articles 13 and 14 of Regulation (EU) 2016/679.
**8.4 Accuracy**
If the data importer becomes aware that the personal data it has received is inaccurate, or has become outdated, it shall inform the data exporter without undue delay. In this case, the data importer shall cooperate with the data exporter to rectify or erase the data.
**8.5 Duration of processing and erasure or return of data**
Processing by the data importer shall only take place for the duration specified in Annex I.B. After the end of the provision of the processing services, the data importer shall, at the choice of the data exporter, delete all personal data processed on behalf of the data exporter (under Module Three, on behalf of the controller) and certify to the data exporter that it has done so, or return to the data exporter all personal data processed on its behalf and delete existing copies. Until the data is deleted or returned, the data importer shall continue to ensure compliance with these Clauses. In case of local laws applicable to the data importer that prohibit return or deletion of the personal data, the data importer warrants that it will continue to ensure compliance with these Clauses and will only process it to the extent and for as long as required under that local law. This is without prejudice to Clause 14, in particular the requirement for the data importer under Clause 14(e) to notify the data exporter throughout the duration of the contract if it has reason to believe that it is or has become subject to laws or practices not in line with the requirements under Clause 14(a).
**8.6 Security of processing**
(a) The data importer and, during transmission, also the data exporter shall implement appropriate technical and organisational measures to ensure the security of the data, including protection against a breach of security leading to accidental or unlawful destruction, loss, alteration, unauthorised disclosure or access to that data (hereinafter 'personal data breach'). In assessing the appropriate level of security, the Parties shall take due account of the state of the art, the costs of implementation, the nature, scope, context and purpose(s) of processing and the risks involved in the processing for the data subjects. The Parties shall in particular consider having recourse to encryption or pseudonymisation, including during transmission, where the purpose of processing can be fulfilled in that manner. In case of pseudonymisation, the additional information for attributing the personal data to a specific data subject shall, where possible, remain under the exclusive control of the data exporter or, where the data exporter is itself a processor, the controller. In complying with its obligations under this paragraph, the data importer shall at least implement the technical and organisational measures specified in Annex II. The data importer shall carry out regular checks to ensure that these measures continue to provide an appropriate level of security.
(b) The data importer shall grant access to the personal data to members of its personnel only to the extent strictly necessary for the implementation, management and monitoring of the contract. It shall ensure that persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality.
(c) In the event of a personal data breach concerning personal data processed by the data importer under these Clauses, the data importer shall take appropriate measures to address the breach, including measures to mitigate its adverse effects. The data importer shall also notify, without undue delay, the data exporter (under Module Three, and, where appropriate and feasible, the controller) after having become aware of the breach. Such notification shall contain the details of a contact point where more information can be obtained, a description of the nature of the breach (including, where possible, categories and approximate number of data subjects and personal data records concerned), its likely consequences and the measures taken or proposed to address the breach, including, where appropriate, measures to mitigate its possible adverse effects. Where, and in so far as, it is not possible to provide all information at the same time, the initial notification shall contain the information then available and further information shall, as it becomes available, subsequently be provided without undue delay.
(d) The data importer shall cooperate with and assist the data exporter to enable the data exporter to comply with its obligations under Regulation (EU) 2016/679, in particular:
> **MODULE TWO (Controller to Processor):** to notify the competent supervisory authority and the affected data subjects, taking into account the nature of processing and the information available to the data importer.
>
> **MODULE THREE (Processor to Processor):** to notify its controller so that the latter may in turn notify the competent supervisory authority and the affected data subjects, taking into account the nature of processing and the information available to the data importer.
**8.7 Sensitive data**
Where the transfer involves personal data revealing racial or ethnic origin, political opinions, religious or philosophical beliefs, or trade union membership, genetic data, or biometric data for the purpose of uniquely identifying a natural person, data concerning health or a person's sex life or sexual orientation, or data relating to criminal convictions and offences (hereinafter 'sensitive data'), the data importer shall apply the specific restrictions and/or additional safeguards set out in Annex I.B.
**8.8 Onward transfers**
The data importer shall only disclose the personal data to a third party on documented instructions:
> **MODULE TWO (Controller to Processor):** from the data exporter.
>
> **MODULE THREE (Processor to Processor):** from the controller, as communicated to the data importer by the data exporter.
In addition, the data may only be disclosed to a third party located outside the European Union ([^3]) (in the same country as the data importer or in another third country, hereinafter 'onward transfer') if the third party is or agrees to be bound by these Clauses, under the appropriate Module, or if:
(i) the onward transfer is to a country benefitting from an adequacy decision pursuant to Article 45 of Regulation (EU) 2016/679 that covers the onward transfer;
(ii) the third party otherwise ensures appropriate safeguards pursuant to Articles 46 or 47 of Regulation (EU) 2016/679 with respect to the processing in question;
(iii) the onward transfer is necessary for the establishment, exercise or defence of legal claims in the context of specific administrative, regulatory or judicial proceedings; or
(iv) the onward transfer is necessary in order to protect the vital interests of the data subject or of another natural person.
Any onward transfer is subject to compliance by the data importer with all the other safeguards under these Clauses, in particular purpose limitation.
**8.9 Documentation and compliance**
> **MODULE TWO (Controller to Processor):**
>
> (a) The data importer shall promptly and adequately deal with enquiries from the data exporter that relate to the processing under these Clauses.
>
> (b) The Parties shall be able to demonstrate compliance with these Clauses. In particular, the data importer shall keep appropriate documentation on the processing activities carried out on behalf of the data exporter.
>
> (c) The data importer shall make available to the data exporter all information necessary to demonstrate compliance with the obligations set out in these Clauses and at the data exporter's request, allow for and contribute to audits of the processing activities covered by these Clauses, at reasonable intervals or if there are indications of non-compliance. In deciding on a review or audit, the data exporter may take into account relevant certifications held by the data importer.
>
> (d) The data exporter may choose to conduct the audit by itself or mandate an independent auditor. Audits may include inspections at the premises or physical facilities of the data importer and shall, where appropriate, be carried out with reasonable notice.
>
> (e) The Parties shall make the information referred to in paragraphs (b) and (c), including the results of any audits, available to the competent supervisory authority on request.
> **MODULE THREE (Processor to Processor):**
>
> (a) The data importer shall promptly and adequately deal with enquiries from the data exporter or the controller that relate to the processing under these Clauses.
>
> (b) The Parties shall be able to demonstrate compliance with these Clauses. In particular, the data importer shall keep appropriate documentation on the processing activities carried out on behalf of the controller.
>
> (c) The data importer shall make all information necessary to demonstrate compliance with the obligations set out in these Clauses available to the data exporter, which shall provide it to the controller.
>
> (d) The data importer shall allow for and contribute to audits by the data exporter of the processing activities covered by these Clauses, at reasonable intervals or if there are indications of non-compliance. The same shall apply where the data exporter requests an audit on instructions of the controller. In deciding on an audit, the data exporter may take into account relevant certifications held by the data importer.
>
> (e) Where the audit is carried out on the instructions of the controller, the data exporter shall make the results available to the controller.
>
> (f) The data exporter may choose to conduct the audit by itself or mandate an independent auditor. Audits may include inspections at the premises or physical facilities of the data importer and shall, where appropriate, be carried out with reasonable notice.
>
> (g) The Parties shall make the information referred to in paragraphs (b) and (c), including the results of any audits, available to the competent supervisory authority on request.
***Clause 9***
**Use of sub-processors**
> **MODULE TWO (Controller to Processor):**
>
> (a) OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the data exporter's general authorisation for the engagement of sub-processor(s) from an agreed list. The data importer shall specifically inform the data exporter in writing of any intended changes to that list through the addition or replacement of sub-processors at least 30 days in advance, thereby giving the data exporter sufficient time to be able to object to such changes prior to the engagement of the sub-processor(s). The data importer shall provide the data exporter with the information necessary to enable the data exporter to exercise its right to object. The list of sub-processors already authorised by the data exporter can be found in Annex III.
> **MODULE THREE (Processor to Processor):**
>
> (a) OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the controller's general authorisation for the engagement of sub-processor(s) from an agreed list. The data importer shall specifically inform the controller in writing of any intended changes to that list through the addition or replacement of sub-processors at least 30 days in advance, thereby giving the controller sufficient time to be able to object to such changes prior to the engagement of the sub-processor(s). The data importer shall provide the controller with the information necessary to enable the controller to exercise its right to object. The data importer shall inform the data exporter of the engagement of the sub-processor(s). The list of sub-processors already authorised by the controller can be found in Annex III.
(b) Where the data importer engages a sub-processor to carry out specific processing activities (on behalf of the data exporter, or under Module Three on behalf of the controller), it shall do so by way of a written contract that provides for, in substance, the same data protection obligations as those binding the data importer under these Clauses, including in terms of third-party beneficiary rights for data subjects. ([^4]) The Parties agree that, by complying with this Clause, the data importer fulfils its obligations under Clause 8.8. The data importer shall ensure that the sub-processor complies with the obligations to which the data importer is subject pursuant to these Clauses.
(c) The data importer shall provide, at the data exporter's request (under Module Three, at the data exporter's or controller's request), a copy of such a sub-processor agreement and any subsequent amendments. To the extent necessary to protect business secrets or other confidential information, including personal data, the data importer may redact the text of the agreement prior to sharing a copy.
(d) The data importer shall remain fully responsible to the data exporter for the performance of the sub-processor's obligations under its contract with the data importer. The data importer shall notify the data exporter of any failure by the sub-processor to fulfil its obligations under that contract.
(e) The data importer shall agree a third-party beneficiary clause with the sub-processor whereby – in the event the data importer has factually disappeared, ceased to exist in law or has become insolvent – the data exporter shall have the right to terminate the sub-processor contract and to instruct the sub-processor to erase or return the personal data.
***Clause 10***
**Data subject rights**
> **MODULE TWO (Controller to Processor):**
>
> (a) The data importer shall promptly notify the data exporter of any request it has received from a data subject. It shall not respond to that request itself unless it has been authorised to do so by the data exporter.
>
> (b) The data importer shall assist the data exporter in fulfilling its obligations to respond to data subjects' requests for the exercise of their rights under Regulation (EU) 2016/679. In this regard, the Parties shall set out in Annex II the appropriate technical and organisational measures, taking into account the nature of the processing, by which the assistance shall be provided, as well as the scope and the extent of the assistance required.
>
> (c) In fulfilling its obligations under paragraphs (a) and (b), the data importer shall comply with the instructions from the data exporter.
> **MODULE THREE (Processor to Processor):**
>
> (a) The data importer shall promptly notify the data exporter and, where appropriate, the controller of any request it has received from a data subject, without responding to that request unless it has been authorised to do so by the controller.
>
> (b) The data importer shall assist, where appropriate in cooperation with the data exporter, the controller in fulfilling its obligations to respond to data subjects' requests for the exercise of their rights under Regulation (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable. In this regard, the Parties shall set out in Annex II the appropriate technical and organisational measures, taking into account the nature of the processing, by which the assistance shall be provided, as well as the scope and the extent of the assistance required.
>
> (c) In fulfilling its obligations under paragraphs (a) and (b), the data importer shall comply with the instructions from the controller, as communicated by the data exporter.
***Clause 11***
**Redress**
(a) The data importer shall inform data subjects in a transparent and easily accessible format, through individual notice or on its website, of a contact point authorised to handle complaints. It shall deal promptly with any complaints it receives from a data subject.
[OPTION: The data importer agrees that data subjects may also lodge a complaint with an independent dispute resolution body ([^5]) at no cost to the data subject. It shall inform the data subjects, in the manner set out in paragraph (a), of such redress mechanism and that they are not required to use it, or follow a particular sequence in seeking redress.]
(b) In case of a dispute between a data subject and one of the Parties as regards compliance with these Clauses, that Party shall use its best efforts to resolve the issue amicably in a timely fashion. The Parties shall keep each other informed about such disputes and, where appropriate, cooperate in resolving them.
(c) Where the data subject invokes a third-party beneficiary right pursuant to Clause 3, the data importer shall accept the decision of the data subject to:
(i) lodge a complaint with the supervisory authority in the Member State of his/her habitual residence or place of work, or the competent supervisory authority pursuant to Clause 13;
(ii) refer the dispute to the competent courts within the meaning of Clause 18.
(d) The Parties accept that the data subject may be represented by a not-for-profit body, organisation or association under the conditions set out in Article 80(1) of Regulation (EU) 2016/679.
(e) The data importer shall abide by a decision that is binding under the applicable EU or Member State law.
(f) The data importer agrees that the choice made by the data subject will not prejudice his/her substantive and procedural rights to seek remedies in accordance with applicable laws.
***Clause 12***
**Liability**
(a) Each Party shall be liable to the other Party/ies for any damages it causes the other Party/ies by any breach of these Clauses.
(b) The data importer shall be liable to the data subject, and the data subject shall be entitled to receive compensation, for any material or non-material damages the data importer or its sub-processor causes the data subject by breaching the third-party beneficiary rights under these Clauses.
(c) Notwithstanding paragraph (b), the data exporter shall be liable to the data subject, and the data subject shall be entitled to receive compensation, for any material or non-material damages the data exporter or the data importer (or its sub-processor) causes the data subject by breaching the third-party beneficiary rights under these Clauses. This is without prejudice to the liability of the data exporter and, where the data exporter is a processor acting on behalf of a controller, to the liability of the controller under Regulation (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable.
(d) The Parties agree that if the data exporter is held liable under paragraph (c) for damages caused by the data importer (or its sub-processor), it shall be entitled to claim back from the data importer that part of the compensation corresponding to the data importer's responsibility for the damage.
(e) Where more than one Party is responsible for any damage caused to the data subject as a result of a breach of these Clauses, all responsible Parties shall be jointly and severally liable and the data subject is entitled to bring an action in court against any of these Parties.
(f) The Parties agree that if one Party is held liable under paragraph (e), it shall be entitled to claim back from the other Party/ies that part of the compensation corresponding to its/their responsibility for the damage.
(g) The data importer may not invoke the conduct of a sub-processor to avoid its own liability.
***Clause 13***
**Supervision**
(a) [Where the data exporter is established in an EU Member State:] The supervisory authority with responsibility for ensuring compliance by the data exporter with Regulation (EU) 2016/679 as regards the data transfer, as indicated in Annex I.C, shall act as competent supervisory authority.
[Where the data exporter is not established in an EU Member State, but falls within the territorial scope of application of Regulation (EU) 2016/679 in accordance with its Article 3(2) and has appointed a representative pursuant to Article 27(1) of Regulation (EU) 2016/679:] The supervisory authority of the Member State in which the representative within the meaning of Article 27(1) of Regulation (EU) 2016/679 is established, as indicated in Annex I.C, shall act as competent supervisory authority.
[Where the data exporter is not established in an EU Member State, but falls within the territorial scope of application of Regulation (EU) 2016/679 in accordance with its Article 3(2) without however having to appoint a representative pursuant to Article 27(2) of Regulation (EU) 2016/679:] The supervisory authority of one of the Member States in which the data subjects whose personal data is transferred under these Clauses in relation to the offering of goods or services to them, or whose behaviour is monitored, are located, as indicated in Annex I.C, shall act as competent supervisory authority.
(b) The data importer agrees to submit itself to the jurisdiction of and cooperate with the competent supervisory authority in any procedures aimed at ensuring compliance with these Clauses. In particular, the data importer agrees to respond to enquiries, submit to audits and comply with the measures adopted by the supervisory authority, including remedial and compensatory measures. It shall provide the supervisory authority with written confirmation that the necessary actions have been taken.
**SECTION III – LOCAL LAWS AND OBLIGATIONS IN CASE OF ACCESS BY PUBLIC AUTHORITIES**
***Clause 14***
**Local laws and practices affecting compliance with the Clauses**
(a) The Parties warrant that they have no reason to believe that the laws and practices in the third country of destination applicable to the processing of the personal data by the data importer, including any requirements to disclose personal data or measures authorising access by public authorities, prevent the data importer from fulfilling its obligations under these Clauses. This is based on the understanding that laws and practices that respect the essence of the fundamental rights and freedoms and do not exceed what is necessary and proportionate in a democratic society to safeguard one of the objectives listed in Article 23(1) of Regulation (EU) 2016/679, are not in contradiction with these Clauses.
(b) The Parties declare that in providing the warranty in paragraph (a), they have taken due account in particular of the following elements:
(i) the specific circumstances of the transfer, including the length of the processing chain, the number of actors involved and the transmission channels used; intended onward transfers; the type of recipient; the purpose of processing; the categories and format of the transferred personal data; the economic sector in which the transfer occurs; the storage location of the data transferred;
(ii) the laws and practices of the third country of destination – including those requiring the disclosure of data to public authorities or authorising access by such authorities – relevant in light of the specific circumstances of the transfer, and the applicable limitations and safeguards ([^6]);
(iii) any relevant contractual, technical or organisational safeguards put in place to supplement the safeguards under these Clauses, including measures applied during transmission and to the processing of the personal data in the country of destination.
(c) The data importer warrants that, in carrying out the assessment under paragraph (b), it has made its best efforts to provide the data exporter with relevant information and agrees that it will continue to cooperate with the data exporter in ensuring compliance with these Clauses.
(d) The Parties agree to document the assessment under paragraph (b) and make it available to the competent supervisory authority on request.
(e) The data importer agrees to notify the data exporter promptly if, after having agreed to these Clauses and for the duration of the contract, it has reason to believe that it is or has become subject to laws or practices not in line with the requirements under paragraph (a), including following a change in the laws of the third country or a measure (such as a disclosure request) indicating an application of such laws in practice that is not in line with the requirements in paragraph (a). (Under Module Three, the data exporter shall forward the notification to the controller.)
(f) Following a notification pursuant to paragraph (e), or if the data exporter otherwise has reason to believe that the data importer can no longer fulfil its obligations under these Clauses, the data exporter shall promptly identify appropriate measures (e.g. technical or organisational measures to ensure security and confidentiality) to be adopted by the data exporter and/or data importer to address the situation (under Module Three, if appropriate in consultation with the controller). The data exporter shall suspend the data transfer if it considers that no appropriate safeguards for such transfer can be ensured, or if instructed by the competent supervisory authority (under Module Three, by the controller or the competent supervisory authority) to do so. In this case, the data exporter shall be entitled to terminate the contract, insofar as it concerns the processing of personal data under these Clauses. If the contract involves more than two Parties, the data exporter may exercise this right to termination only with respect to the relevant Party, unless the Parties have agreed otherwise. Where the contract is terminated pursuant to this Clause, Clause 16(d) and (e) shall apply.
***Clause 15***
**Obligations of the data importer in case of access by public authorities**
**15.1 Notification**
(a) The data importer agrees to notify the data exporter and, where possible, the data subject promptly (if necessary with the help of the data exporter) if it:
(i) receives a legally binding request from a public authority, including judicial authorities, under the laws of the country of destination for the disclosure of personal data transferred pursuant to these Clauses; such notification shall include information about the personal data requested, the requesting authority, the legal basis for the request and the response provided; or
(ii) becomes aware of any direct access by public authorities to personal data transferred pursuant to these Clauses in accordance with the laws of the country of destination; such notification shall include all information available to the importer.
(Under Module Three, the data exporter shall forward the notification to the controller.)
(b) If the data importer is prohibited from notifying the data exporter and/or the data subject under the laws of the country of destination, the data importer agrees to use its best efforts to obtain a waiver of the prohibition, with a view to communicating as much information as possible, as soon as possible. The data importer agrees to document its best efforts in order to be able to demonstrate them on request of the data exporter.
(c) Where permissible under the laws of the country of destination, the data importer agrees to provide the data exporter, at regular intervals for the duration of the contract, with as much relevant information as possible on the requests received (in particular, number of requests, type of data requested, requesting authority/ies, whether requests have been challenged and the outcome of such challenges, etc.). (Under Module Three, the data exporter shall forward the information to the controller.)
(d) The data importer agrees to preserve the information pursuant to paragraphs (a) to (c) for the duration of the contract and make it available to the competent supervisory authority on request.
(e) Paragraphs (a) to (c) are without prejudice to the obligation of the data importer pursuant to Clause 14(e) and Clause 16 to inform the data exporter promptly where it is unable to comply with these Clauses.
**15.2 Review of legality and data minimisation**
(a) The data importer agrees to review the legality of the request for disclosure, in particular whether it remains within the powers granted to the requesting public authority, and to challenge the request if, after careful assessment, it concludes that there are reasonable grounds to consider that the request is unlawful under the laws of the country of destination, applicable obligations under international law and principles of international comity. The data importer shall, under the same conditions, pursue possibilities of appeal. When challenging a request, the data importer shall seek interim measures with a view to suspending the effects of the request until the competent judicial authority has decided on its merits. It shall not disclose the personal data requested until required to do so under the applicable procedural rules. These requirements are without prejudice to the obligations of the data importer under Clause 14(e).
(b) The data importer agrees to document its legal assessment and any challenge to the request for disclosure and, to the extent permissible under the laws of the country of destination, make the documentation available to the data exporter. It shall also make it available to the competent supervisory authority on request. (Under Module Three, the data exporter shall make the assessment available to the controller.)
(c) The data importer agrees to provide the minimum amount of information permissible when responding to a request for disclosure, based on a reasonable interpretation of the request.
**SECTION IV – FINAL PROVISIONS**
***Clause 16***
**Non-compliance with the Clauses and termination**
(a) The data importer shall promptly inform the data exporter if it is unable to comply with these Clauses, for whatever reason.
(b) In the event that the data importer is in breach of these Clauses or unable to comply with these Clauses, the data exporter shall suspend the transfer of personal data to the data importer until compliance is again ensured or the contract is terminated. This is without prejudice to Clause 14(f).
(c) The data exporter shall be entitled to terminate the contract, insofar as it concerns the processing of personal data under these Clauses, where:
(i) the data exporter has suspended the transfer of personal data to the data importer pursuant to paragraph (b) and compliance with these Clauses is not restored within a reasonable time and in any event within one month of suspension;
(ii) the data importer is in substantial or persistent breach of these Clauses; or
(iii) the data importer fails to comply with a binding decision of a competent court or supervisory authority regarding its obligations under these Clauses.
In these cases, it shall inform the competent supervisory authority (under Module Three, and the controller) of such non-compliance. Where the contract involves more than two Parties, the data exporter may exercise this right to termination only with respect to the relevant Party, unless the Parties have agreed otherwise.
(d) Personal data that has been transferred prior to the termination of the contract pursuant to paragraph (c) shall at the choice of the data exporter immediately be returned to the data exporter or deleted in its entirety. The same shall apply to any copies of the data. The data importer shall certify the deletion of the data to the data exporter. Until the data is deleted or returned, the data importer shall continue to ensure compliance with these Clauses. In case of local laws applicable to the data importer that prohibit the return or deletion of the transferred personal data, the data importer warrants that it will continue to ensure compliance with these Clauses and will only process the data to the extent and for as long as required under that local law.
(e) Either Party may revoke its agreement to be bound by these Clauses where (i) the European Commission adopts a decision pursuant to Article 45(3) of Regulation (EU) 2016/679 that covers the transfer of personal data to which these Clauses apply; or (ii) Regulation (EU) 2016/679 becomes part of the legal framework of the country to which the personal data is transferred. This is without prejudice to other obligations applying to the processing in question under Regulation (EU) 2016/679.
***Clause 17***
**Governing law**
These Clauses shall be governed by the law of one of the EU Member States, provided such law allows for third-party beneficiary rights. The Parties agree that this shall be the law of the Member State indicated in Annex I.C.
***Clause 18***
**Choice of forum and jurisdiction**
(a) Any dispute arising from these Clauses shall be resolved by the courts of an EU Member State.
(b) The Parties agree that those shall be the courts of the Member State indicated in Annex I.C.
(c) A data subject may also bring legal proceedings against the data exporter and/or data importer before the courts of the Member State in which he/she has his/her habitual residence.
(d) The Parties agree to submit themselves to the jurisdiction of such courts.
[^1]: Where the data exporter is a processor subject to Regulation (EU) 2016/679 acting on behalf of a Union institution or body as controller, reliance on these Clauses when engaging another processor (sub-processing) not subject to Regulation (EU) 2016/679 also ensures compliance with Article 29(4) of Regulation (EU) 2018/1725 of the European Parliament and of the Council of 23 October 2018 on the protection of natural persons with regard to the processing of personal data by the Union institutions, bodies, offices and agencies and on the free movement of such data, and repealing Regulation (EC) No 45/2001 and Decision No 1247/2002/EC ([OJ L 295, 21.11.2018, p. 39](https://eur-lex.europa.eu/legal-content/EN/AUTO/?uri=OJ:L:2018:295:TOC)), to the extent these Clauses and the data protection obligations as set out in the contract or other legal act between the controller and the processor pursuant to Article 29(3) of Regulation (EU) 2018/1725 are aligned. This will in particular be the case where the controller and processor rely on the standard contractual clauses included in Decision 2021/915.
[^2]: See Article 28(4) of Regulation (EU) 2016/679 and, where the controller is an EU institution or body, Article 29(4) of Regulation (EU) 2018/1725.
[^3]: The Agreement on the European Economic Area (EEA Agreement) provides for the extension of the European Union's internal market to the three EEA States Iceland, Liechtenstein and Norway. The Union data protection legislation, including Regulation (EU) 2016/679, is covered by the EEA Agreement and has been incorporated into Annex XI thereto. Therefore, any disclosure by the data importer to a third party located in the EEA does not qualify as an onward transfer for the purposes of these Clauses.
[^4]: This requirement may be satisfied by the sub-processor acceding to these Clauses under the appropriate Module, in accordance with Clause 7.
[^5]: The data importer may offer independent dispute resolution through an arbitration body only if it is established in a country that has ratified the New York Convention on Enforcement of Arbitration Awards.
[^6]: As regards the impact of such laws and practices on compliance with these Clauses, different elements may be considered as part of an overall assessment. Such elements may include relevant and documented practical experience with prior instances of requests for disclosure from public authorities, or the absence of such requests, covering a sufficiently representative time-frame. This refers in particular to internal records or other documentation, drawn up on a continuous basis in accordance with due diligence and certified at senior management level, provided that this information can be lawfully shared with third parties. Where this practical experience is relied upon to conclude that the data importer will not be prevented from complying with these Clauses, it needs to be supported by other relevant, objective elements, and it is for the Parties to consider carefully whether these elements together carry sufficient weight, in terms of their reliability and representativeness, to support this conclusion. In particular, the Parties have to take into account whether their practical experience is corroborated and not contradicted by publicly available or otherwise accessible, reliable information on the existence or absence of requests within the same sector and/or the application of the law in practice, such as case law and reports by independent oversight bodies.
---
## Annex 2: Security measures
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/dpa).
:::
**Last updated**: 12 June 2026
**Technical and organisational measures to ensure the security of the data**
This Annex forms part of the DPA. Holistics currently observes the security practices described in this Annex 2.
Notwithstanding any provision to the contrary otherwise agreed to by Customer, Holistics may modify or update these practices at its discretion provided that such modification and update does not result in a material degradation in the protection offered by these practices.
All capitalized terms not otherwise defined herein shall have the meanings as set forth in the Holistics Terms of Service (Terms) stated at https://www.holistics.io/terms/.
## a) Access control
### i) Preventing unauthorized product access
- **Outsourced processing**: Holistics hosts its Service with Digital Ocean, a data center provider based in Germany, the United States, and Singapore. Additionally, Holistics maintains contractual relationships with vendors in order to provide the Service. Holistics relies on contractual agreements, privacy policies, and vendor compliance programs in order to assure the protection of data processed or stored by these vendors.
- **Physical and environmental security**: Our servers for the Subscription Service are hosted with Digital Ocean and Amazon Web Services. Our data centres are based in the United States, Germany, and Singapore.
- **Authentication**: Customers who interact with Holistics software must authenticate before accessing non-public customer data.
- **Authorization**: Customer data is stored in multi-tenant storage systems accessible to Customers via only application user interfaces and application programming interfaces. Customers are not allowed direct access to the underlying application infrastructure.
The authorization model in each of Holistics' products is designed to ensure that only the appropriately assigned individuals can access relevant features, views, and customization options. Authorization to data sets is performed through validating the user's permissions against the attributes associated with each data set.
- **Application Programming Interface (API) access**: Holistics allows the customer to expose public product APIs using an API key.
### ii) Preventing unauthorized product use
Holistics implements industry standard access controls and detection capabilities for the internal networks that support its products.
- **Access controls**: Network access control mechanisms are designed to prevent network traffic using unauthorized protocols from reaching the product infrastructure.
- **Static code analysis**: Security reviews of code stored in Holistics' source code repositories is performed, checking for coding best practices and identifiable software flaws.
- **Responsible disclosure**: A responsible disclosure program invites and incentivizes independent security researchers to ethically discover and disclose security flaws. This widens the available opportunities to engage with the security community and improve the product defenses against sophisticated attacks.
### iii) Limitations of privilege and authorization requirements
**Internal data access by personnel**:
- Access to the infrastructure is restricted to authorized personnel on the principle of least privilege.
- SSH users use unique accounts to access production machines, and the root account is not used.
- Access to sensitive systems and applications requires two-factor authentication in the form of user ID, password, OTP, and/or certificate.
- Holistics has established formal guidelines for passwords to govern the management and use of authentication mechanisms. All access is logged, and removed when appropriate.
- Access to the corporate network, production machines, network devices, and support tools requires a unique ID.
## b) Transmission control
**In-transit**:
- Holistics ensures that all connections to its web application from its users are encrypted.
- Holistics uses configurations that ensure only approved networking ports and protocols are implemented, including firewalls.
- Management has implemented tools to log network traffic into a system that allows monitoring and ad hoc queries.
**At-rest**:
- Holistics encrypts Customer's database connection credentials and cached data stored at rest.
- Access to sensitive systems and applications requires two-factor authentication in the form of user ID, password, OTP, and/or certificate.
- Only authorized users with the correct SSH key may gain access to production machines.
## c) Input control
- **Detection**: Holistics designed its infrastructure to log extensive information about the system behavior, traffic received, system authentication, and other application requests. Internal systems aggregated log data and alert appropriate employees of malicious, unintended, or anomalous activities. Holistics personnel, including security, operations, and support personnel, are responsive to known incidents.
- **Response and tracking**: Holistics maintains a record of known security incidents that includes description, dates and times of relevant activities, and incident disposition. Suspected and confirmed security incidents are investigated by security, operations, or support personnel; and appropriate resolution steps are identified and documented. For any confirmed incidents, Holistics will take appropriate steps to minimize product and Customer damage or unauthorized disclosure.
- **Communication**: If Holistics becomes aware of unlawful access to Customer data stored within its products, Holistics will:
1. Notify the affected Customers of the incident;
2. Provide a description of the steps Holistics is taking to resolve the incident; and
3. Provide status updates to the Customer contact, as Holistics deems necessary.
Notification(s) of incidents, if any, will be delivered to one or more of the Customer's contacts in a form Holistics selects, which may include via email or telephone.
## d) Data storage
Unlike most business intelligence software, Holistics Software does not store any physical records of Customer Data permanently. Instead Holistics generates SQL that directly queries the database and visualizes the records in the browser.
- **Terminating customers**: Holistics Metadata in active (i.e. primary) databases is purged 180 days after a customer terminates all agreements for such products with Holistics, or upon a customer's written request. Information stored in backups, replicas, and snapshots is not automatically purged, but instead ages out of the system as part of the data lifecycle. Holistics reserves the right to alter data purging periods in order to address technical, compliance, or statutory requirements.
## e) Availability control
- **Infrastructure availability**: The data center providers use commercially reasonable efforts to ensure a minimum of 99.9% uptime. The providers maintain a minimum of N+1 redundancy to power, network, and HVAC services.
- **Fault tolerance**: Backup and replication strategies are designed to ensure redundancy and fail-over protections during a significant processing failure.
Holistics' products are designed to ensure redundancy and seamless failover. The server instances that support the products are also architected with a goal to prevent single points of failure. This design assists Holistics operations in maintaining and updating the product applications and backend while limiting downtime.
## f) Event logging
Holistics has implemented tools to:
- collect and store server logs in a central location, which authorized users can query in an ad hoc fashion.
- log application state and network traffic into systems that allow monitoring and ad hoc queries.
- monitor the following components and notify appropriate personnel of any events or incidents based on predetermined criteria, with incidents escalated per policy:
- Holistics Software SQL databases
- load balancers
- messaging queues
- servers
- retain log entries **for at least 12 months**.
%%SIGNATORY%%
---
## Annex 3: List of Holistics sub-processors
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/dpa).
:::
**Last updated**: 13 June 2026
"Sub-Processors for Customer Database" refers to the service providers that are used by Holistics as described in the DPA ([https://docs.holistics.io/legal/dpa](https://docs.holistics.io/legal/dpa)).
### Customer Database Sub-processors
These sub-processors host or process data from the Customer's connected database.
**No duplicate warehouse of the Customer Database.** Holistics is not a data warehouse and does not create a separate, persistent copy of the physical data records in the Customer Database. The sub-processors below provide the hosting and compute infrastructure through which Holistics queries the Customer Database on demand to serve the Subscription Services. As an intermediary processor, Holistics retains only transient artefacts: (a) Temporary Cached Query Results, which hold the records returned by a query for the limited, Customer-configurable period described in [Annex 1](/legal/annex-subject-matter) (minimum 10 minutes) before they automatically expire; and (b) exported files (Excel/CSV), retained for up to 24 hours. Outside these transient artefacts, the physical data records residing in the Customer Database are not stored or warehoused by Holistics.
| Sub-processor | Entity Country | Location of Servers | Purpose |
| --- | --- | --- | --- |
| Amazon Web Services, Inc. | USA | USA, Germany, Singapore | Amazon S3, RDS and/or other AWS services |
| DigitalOcean, Inc. | USA | USA, Germany, Singapore | Infrastructure hosting |
| Mailgun, Inc. (Sinch) | USA | USA, EU | Transactional emails, scheduled email reports, and data alerts |
### Service-Specific Sub-processors
These sub-processors process data to provide specific features within the Services.
| Sub-processor | Entity Country | Purpose |
| --- | --- | --- |
| Cloudflare, Inc. | USA | Content delivery, network performance optimization, security, abuse prevention, and DNS |
| OpenAI, Inc. | USA | LLM provider for AI-powered features |
### Usage and Metadata Sub-processors
These sub-processors process Holistics metadata and usage data for internal analytics. They do not process the content of the Customer's connected database. This data is retained only as long as needed for these purposes, in accordance with the Holistics data retention policy ([https://docs.holistics.io/docs/security-compliance/data-retention](https://docs.holistics.io/docs/security-compliance/data-retention)) and, as stated in [Annex 1](/legal/annex-subject-matter), is removed within 180 days after the Term ends, or earlier upon the Customer's request to the Holistics support team.
| Sub-processor | Entity Country | Purpose |
| --- | --- | --- |
| Google, Inc. | USA | Google Cloud Platform (GCP), BigQuery |
| Anthropic, PBC | USA | LLM provider for data analytics |
| OpenAI, Inc. | USA | LLM provider for data analytics |
| GitHub, Inc. | USA | Source control hosting for AMQL/analytics definitions and logic |
### AI sub-processor data protections
For the customer-facing AI-powered features, Holistics uses its own key with OpenAI by default, and OpenAI acts as a Holistics sub-processor for those features. Holistics configures the processing so that the provider:
- does not use Customer Data to train or improve its models;
- operates under zero or minimal data retention, so that Customer Data is not retained by the provider after the response is returned; and
- does not log the content of API requests, where the provider offers that control.
These configurations form part of the data protection terms Holistics imposes on its sub-processors under the "Customer Database Sub-Processors" section of the [DPA](/legal/dpa). The specific provider settings reflect each provider's current capabilities and may change as providers update their offerings; where a setting changes, Holistics will maintain protections that are materially equivalent. The current configuration and the controls available to the Customer are described at [https://docs.holistics.io/docs/ai/data-access-and-policy](https://docs.holistics.io/docs/ai/data-access-and-policy).
### LLM provider keys (bring-your-own-key)
Where the Customer uses the bring-your-own-key (BYOK) option and supplies its own LLM provider API key (for example, an OpenAI or Anthropic key), the Customer engages that provider directly under the Customer's own account and agreement, and that provider is not a Holistics sub-processor for the processing performed under the Customer's key.
%%SIGNATORY%%
---
## Annex 1: Subject matter and details of data processing
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/dpa).
:::
**Last updated**: 13 June 2026
## A. List of parties
The data exporter is the Customer, a non-Holistics entity, as defined in the Holistics Terms of Service (Terms) at [https://www.holistics.io/terms/](https://www.holistics.io/terms/). The data importer is Holistics.
| | Data exporter (Customer) | Data importer (Holistics) |
|---|---|---|
| **Name** | | Holistics Software Pte Ltd |
| **Address** | | 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545 |
| **Contact person's name** | | %%CONTACT_NAME%% |
| **Contact person's position** | | %%CONTACT_POSITION%% |
| **Contact person's email** | | %%CONTACT_EMAIL%% |
| **Role** | Controller or Processor, determined by operation of the clause below | Processor |
| **Activities relevant to the data transferred under these Clauses** | Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms"). | Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms"). |
**Customer role:**
Customer's role, and therefore the applicable transfer mechanism, is determined by the actual nature of Customer's processing under applicable Data Protection Laws. No box needs to be checked, and an unstated role does not affect the protections that apply:
- Where, and to the extent that, **Customer acts as a controller**, EU SCC Module 2 (Controller to Processor) applies under [Annex 4: EU SCC](/legal/annex-eu-scc).
- Where, and to the extent that, **Customer acts as a processor** on behalf of one or more third-party controllers (for example, in embedded analytics deployments), EU SCC Module 3 (Processor to Processor) applies under [Annex 4: EU SCC](/legal/annex-eu-scc).
- Where Customer's processing is subject to the **UK GDPR**, the UK SCC applies under [Annex 5: UK SCC](/legal/annex-uk-scc), regardless of whether Customer is a controller or processor.
Where Customer's role is mixed (controller for some personal data, processor for other personal data), each mechanism applies to the corresponding processing.
For Module 3 (Processor to Processor), the controller(s) on whose behalf Customer processes the personal data are the controllers identified by Customer; where not separately identified, they are the controllers on whose behalf Customer connects the relevant Customer Database to the Subscription Services. Customer will, on Holistics' reasonable request, supply the information about such controllers that the GDPR requires Holistics to maintain.
## B. Description of transfer
### Data subjects
The personal data transferred concern the following categories of data subjects in two main categories
1. **Customer End Users of the Holistics Subscription Service**, mainly the employees of the Data Exporter, and other individuals who have been invited to access the Holistics Subscription Service in their customer account. This also includes users who have submitted their contact details through the Holistics website.
2. **Data subjects whose data is stored in the Exporter's database** connected to the Holistics application servers that may contain Personal Data.
### Categories of data
1. **Holistics Metadata and Usage Data (From Customer End Users)**
The personal data transferred concern personal data, software license checks, audit trails, website usage information (URLs accessed, time of access, browser type, IP address), email data, metadata on reports and dashboards, data source schemas, encrypted data source connection credentials, and other electronic data submitted, stored, sent, or received by users of the Subscription Service.
2. **Temporary Cached Query Results from the Customer (Exporter)'s database**
Once the Customer's database is connected to the Holistics server, the Holistics cache temporarily retains data from the database that is fetched in response to a user's report queries. The Exporter can reduce the amount of time that query results are held in cache (minimum of 10 minutes).
The categories of personal data within these results are determined and controlled solely by the Customer (the data exporter). Holistics does not control, and is not in a position to know, the specific categories of personal data the Customer queries through the Service. This data relates to the second category of data subjects described above (those whose data is stored in the Exporter's database), limited to the records returned by the Customer's queries.
When a dashboard widget is exported into Excel/CSV file, the file will also be temporarily stored in Holistics' file storage system.
3. **AI Interaction Data (from Customer End Users who use AI-powered features)**
Where the Customer enables Holistics' AI-powered features, the data transferred to the AI (LLM) Sub-processors listed in [Annex 3](/legal/annex-sub-processors) comprises the inputs the Customer chooses to share through its AI settings: object metadata (always), and, where enabled by the Customer, a small sample of source column values and chart result data. To the extent any of this data contains Personal Data, its categories are determined and controlled solely by the Customer. The data accessed by each feature and the controls available to the Customer are described at [https://docs.holistics.io/docs/ai/data-access-and-policy](https://docs.holistics.io/docs/ai/data-access-and-policy).
### Sensitive data transferred and applied restrictions or safeguards
The parties do not anticipate the transfer of sensitive data. In the event sensitive data is stored in Customer's Database, Customer has the flexibility to restrict or isolate sensitive data from the database user credential account that is used to connect to Holistics Software.
### Frequency of the transfer
On a continuous basis, each time a dashboard or query is loaded by a user or executed by a scheduled job configured by the Customer.
### Purpose of the transfer and further processing
Holistics will process data for the purposes of providing the Subscription Services to Customer in accordance with the Holistics Terms of Service ("Terms").
Where the Customer enables AI-powered features, the purpose also includes transmitting the data described above to the AI (LLM) Sub-processors listed in [Annex 3](/legal/annex-sub-processors) to generate the requested AI responses, subject to the protections set out in this DPA and that Annex.
### Period for which data will be retained
Temporary Cached Query Results will be stored for a minimum of 10 minutes (or higher) from the time the dashboard is first accessed.
Exported files (Excel/CSV downloads) are stored for up to 24 hours before they expire automatically.
Holistics Metadata and Usage Data (From Customer End Users) will be removed after 180 days after the Term expires, or earlier upon request by Customer.
AI Interaction Data (conversations with AI-powered features) is encrypted at rest and retained for 30 days, after which it expires and is deleted.
### Competent supervisory authority
For the purposes of the Standard Contractual Clauses, the supervisory authority that shall act as competent supervisory authority is either
1. **Where Customer is established in an EU Member State**, the supervisory authority responsible for ensuring Customer's compliance with the GDPR;
2. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR and has appointed a representative, the supervisory authority of the **EU Member State in which Customer's representative is established**; or
3. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR without having to appoint a representative, the supervisory authority of the EU Member State in **which the Data Subjects are predominantly located** in relation to Data Processed that is subject to the UK GDPR or Swiss DPA, the competent supervisory authority is the UK Information Commissioner or the Swiss Federal Data Protection and Information Commissioner (as applicable).
%%SIGNATORY%%
---
## Annex 5: UK SCC (Controller to Processor)
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/dpa).
:::
**Last updated**: 12 June 2026
**Note:** If there are actual differences between the official UK SCC Module ([published by the ICO](https://ico.org.uk/for-organisations/guide-to-data-protection/guide-to-the-general-data-protection-regulation-gdpr/international-data-transfer-agreement-and-guidance/)) and this version below, the official UK SCC Module prevails.
---
[CONTROLLER TO PROCESSOR MODEL CLAUSES: SET II]
Commission Decision C(2010)593
Standard Contractual Clauses (processors)
For the purposes of Article 26(2) UK GDPR for the transfer of personal data to processors established in third countries which do not ensure an adequate level of data protection
Name of the data exporting organisation: [Transferor]
(the data **exporter** )
And
Name of the data importing organisation: [Transferee]
(the data **importer** )
each a "party"; together "the parties",
HAVE AGREED on the following Contractual Clauses (the Clauses) in order to adduce adequate safeguards with respect to the protection of privacy and fundamental rights and freedoms of individuals for the transfer by the data exporter to the data importer of the personal data specified in Appendix 1.
_Clause 1_
_ **Definitions** _
For the purposes of the Clauses:
(a)'personal data', 'special categories of data', 'process/processing', 'controller', 'processor', 'data subject' and 'supervisory authority' shall have the same meaning as in the UK GDPR;
(b) 'the data exporter' means the controller who transfers the personal data;
(c) 'the data importer' means the processor who agrees to receive from the data exporter personal data intended for processing on his behalf after the transfer in accordance with his instructions and the terms of the Clauses and who is not subject to a third country's system ensuring adequate protection within the meaning of Article 25(1) UK GDPR;
(d) 'the subprocessor' means any processor engaged by the data importer or by any other subprocessor of the data importer who agrees to receive from the data importer or from any other subprocessor of the data importer personal data exclusively intended for processing activities to be carried out on behalf of the data exporter after the transfer in accordance with his instructions, the terms of the Clauses and the terms of the written subcontract;
(e) 'the applicable data protection law **'** means the legislation protecting the fundamental rights and freedoms of individuals and, in particular, their right to privacy with respect to the processing of personal data applicable to a data controller in the United Kingdom;
(f)'technical and organisational security measures' means those measures aimed at protecting personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing.
_Clause 2_
_ **Details of the transfer** _
The details of the transfer and in particular the special categories of personal data where applicable are specified in Appendix 1 which forms an integral part of the Clauses.
_Clause 3_
_ **Third-party beneficiary clause** _
1. The data subject can enforce against the data exporter this Clause, Clause 4(b) to (i), Clause 5(a) to (e), and (g) to (j), Clause 6(1) and (2), Clause 7, Clause 8(2), and Clauses 9 to 12 as third-party beneficiary.
2. The data subject can enforce against the data importer this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where the data exporter has factually disappeared or has ceased to exist in law unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law, as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity.
3. The data subject can enforce against the subprocessor this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
4. The parties do not object to a data subject being represented by an association or other body if the data subject so expressly wishes and if permitted by national law.
_Clause 4_
_ **Obligations of the data exporter** _
The data exporter agrees and warrants:
(a) that the processing, including the transfer itself, of the personal data has been and will continue to be carried out in accordance with the relevant provisions of the applicable data protection law (and, where applicable, has been notified to the relevant authorities in the United Kingdom) and does not violate the relevant provisions of the United Kingdom;
(b) that it has instructed and throughout the duration of the personal data processing services will instruct the data importer to process the personal data transferred only on the data exporter's behalf and in accordance with the applicable data protection law and the Clauses;
(c) that the data importer will provide sufficient guarantees in respect of the technical and organisational security measures specified in Appendix 2 to this contract;
(d) that after assessment of the requirements of the applicable data protection law, the security measures are appropriate to protect personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing, and that these measures ensure a level of security appropriate to the risks presented by the processing and the nature of the data to be protected having regard to the state of the art and the cost of their implementation;
(e) that it will ensure compliance with the security measures;
(f) that, if the transfer involves special categories of data, the data subject has been informed or will be informed before, or as soon as possible after, the transfer that its data could be transmitted to a third country not providing adequate protection within the meaning of the UK GDPR;
(g) to forward any notification received from the data importer or any subprocessor pursuant to Clause 5(b) and Clause 8(3) to the data protection supervisory authority if the data exporter decides to continue the transfer or to lift the suspension;
(h) to make available to the data subjects upon request a copy of the Clauses, with the exception of Appendix 2, and a summary description of the security measures, as well as a copy of any contract for subprocessing services which has to be made in accordance with the Clauses, unless the Clauses or the contract contain commercial information, in which case it may remove such commercial information;
(i) that, in the event of subprocessing, the processing activity is carried out in accordance with Clause 11 by a subprocessor providing at least the same level of protection for the personal data and the rights of data subject as the data importer under the Clauses; and
(j) that it will ensure compliance with Clause 4(a) to (i).
_Clause 5_
_ **Obligations of the data importer [^12]** _
The data importer agrees and warrants:
(a) to process the personal data only on behalf of the data exporter and in compliance with its instructions and the Clauses; if it cannot provide such compliance for whatever reasons, it agrees to inform promptly the data exporter of its inability to comply, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(b) that it has no reason to believe that the legislation applicable to it prevents it from fulfilling the instructions received from the data exporter and its obligations under the contract and that in the event of a change in this legislation which is likely to have a substantial adverse effect on the warranties and obligations provided by the Clauses, it will promptly notify the change to the data exporter as soon as it is aware, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(c) that it has implemented the technical and organisational security measures specified in Appendix 2 before processing the personal data transferred;
(d) that it will promptly notify the data exporter about:
(i) any legally binding request for disclosure of the personal data by a law enforcement authority unless otherwise prohibited, such as a prohibition under criminal law to preserve the confidentiality of a law enforcement investigation,
(ii) any accidental or unauthorised access, and
(iii) any request received directly from the data subjects without responding to that request, unless it has been otherwise authorised to do so;
(e) to deal promptly and properly with all inquiries from the data exporter relating to its processing of the personal data subject to the transfer and to abide by the advice of the supervisory authority with regard to the processing of the data transferred;
(f) at the request of the data exporter to submit its data processing facilities for audit of the processing activities covered by the Clauses which shall be carried out by the data exporter or an inspection body composed of independent members and in possession of the required professional qualifications bound by a duty of confidentiality, selected by the data exporter, where applicable, in agreement with the supervisory authority;
(g) to make available to the data subject upon request a copy of the Clauses, or any existing contract for subprocessing, unless the Clauses or contract contain commercial information, in which case it may remove such commercial information, with the exception of Appendix 2 which shall be replaced by a summary description of the security measures in those cases where the data subject is unable to obtain a copy from the data exporter;
(h) that, in the event of subprocessing, it has previously informed the data exporter and obtained its prior written consent;
(i) that the processing services by the subprocessor will be carried out in accordance with Clause 11;
(j) to send promptly a copy of any subprocessor agreement it concludes under the Clauses to the data exporter.
_Clause 6_
_ **Liability** _
1. The parties agree that any data subject, who has suffered damage as a result of any breach of the obligations referred to in Clause 3 or in Clause 11 by any party or subprocessor is entitled to receive compensation from the data exporter for the damage suffered.
2. If a data subject is not able to bring a claim for compensation in accordance with paragraph 1 against the data exporter, arising out of a breach by the data importer or his subprocessor of any of their obligations referred to in Clause 3 or in Clause 11, because the data exporter has factually disappeared or ceased to exist in law or has become insolvent, the data importer agrees that the data subject may issue a claim against the data importer as if it were the data exporter, unless any successor entity has assumed the entire legal obligations of the data exporter by contract of by operation of law, in which case the data subject can enforce its rights against such entity.
The data importer may not rely on a breach by a subprocessor of its obligations in order to avoid its own liabilities.
3. If a data subject is not able to bring a claim against the data exporter or the data importer referred to in paragraphs 1 and 2, arising out of a breach by the subprocessor of any of their obligations referred to in Clause 3 or in Clause 11 because both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, the subprocessor agrees that the data subject may issue a claim against the data subprocessor with regard to its own processing operations under the Clauses as if it were the data exporter or the data importer, unless any successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law, in which case the data subject can enforce its rights against such entity. The liability of the subprocessor shall be limited to its own processing operations under the Clauses.
_Clause 7_
_ **Mediation and jurisdiction** _
1. The data importer agrees that if the data subject invokes against it third-party beneficiary rights and/or claims compensation for damages under the Clauses, the data importer will accept the decision of the data subject:
(a) to refer the dispute to mediation, by an independent person or, where applicable, by the supervisory authority;
(b) to refer the dispute to the courts in the United Kingdom.
2. The parties agree that the choice made by the data subject will not prejudice its substantive or procedural rights to seek remedies in accordance with other provisions of national or international law.
_Clause 8_
_ **Cooperation with supervisory authorities** _
1. The data exporter agrees to deposit a copy of this contract with the supervisory authority if it so requests or if such deposit is required under the applicable data protection law.
2. The parties agree that the supervisory authority has the right to conduct an audit of the data importer, and of any subprocessor, which has the same scope and is subject to the same conditions as would apply to an audit of the data exporter under the applicable data protection law.
3. The data importer shall promptly inform the data exporter about the existence of legislation applicable to it or any subprocessor preventing the conduct of an audit of the data importer, or any subprocessor, pursuant to paragraph 2. In such a case the data exporter shall be entitled to take the measures foreseen in Clause 5 (b).
_Clause 9_
_ **Governing Law** _
The Clauses shall be governed by the laws of England and Wales.
_Clause 10_
_ **Variation of the contract** _
The parties undertake not to vary or modify the Clauses. This does not preclude the parties from adding clauses on business related issues where required as long as they do not contradict the Clause.
_Clause 11_
_ **Subprocessing** _
1. The data importer shall not subcontract any of its processing operations performed on behalf of the data exporter under the Clauses without the prior written consent of the data exporter. Where the data importer subcontracts its obligations under the Clauses, with the consent of the data exporter, it shall do so only by way of a written agreement with the subprocessor which imposes the same obligations on the subprocessor as are imposed on the data importer under the Clauses[^13]. Where the subprocessor fails to fulfil its data protection obligations under such written agreement the data importer shall remain fully liable to the data exporter for the performance of the subprocessor's obligations under such agreement.
2. The prior written contract between the data importer and the subprocessor shall also provide for a third-party beneficiary clause as laid down in Clause 3 for cases where the data subject is not able to bring the claim for compensation referred to in paragraph 1 of Clause 6 against the data exporter or the data importer because they have factually disappeared or have ceased to exist in law or have become insolvent and no successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
3. The provisions relating to data protection aspects for subprocessing of the contract referred to in paragraph 1 shall be governed by the laws of England and Wales.
4. The data exporter shall keep a list of subprocessing agreements concluded under the Clauses and notified by the data importer pursuant to Clause 5 (j), which shall be updated at least once a year. The list shall be available to the data exporter's data protection supervisory authority.
_Clause 12_
_ **Obligation after the termination of personal data processing services** _
1. The parties agree that on the termination of the provision of data processing services, the data importer and the subprocessor shall, at the choice of the data exporter, return all the personal data transferred and the copies thereof to the data exporter or shall destroy all the personal data and certify to the data exporter that it has done so, unless legislation imposed upon the data importer prevents it from returning or destroying all or part of the personal data transferred. In that case, the data importer warrants that it will guarantee the confidentiality of the personal data transferred and will not actively process the personal data transferred anymore.
2. The data importer and the subprocessor warrant that upon request of the data exporter and/or of the supervisory authority, it will submit its data processing facilities for an audit of the measures referred to in paragraph 1.
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 1 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Data exporter**
The data exporter is (please specify briefly your activities relevant to the transfer):
xx
**Data importer**
The data importer is (please specify briefly activities relevant to the transfer):
xx
**Data subjects**
The personal data transferred concern the following categories of data subjects (please specify):
xx
**Categories of data**
The personal data transferred concern the following categories of data (please specify):
xx
**Special categories of data (if appropriate)**
The personal data transferred concern the following special categories of data (please specify):
xx
**Processing operations**
The personal data transferred will be subject to the following basic processing activities (please specify): [_insert_]
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 2 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Description of the technical and organisational security measures implemented by the data importer in accordance with Clauses 4(d) and 5(c):**
xx
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
---
[^12]: Mandatory requirements of the national legislation applicable to the data importer which do not go beyond what is necessary in a democratic society on the basis of one of the interests listed in Article 13(1) UK GDPR, that is, if they constitute a necessary measure to safeguard national security, defence, public security, the prevention, investigation, detection and prosecution of criminal offences or of breaches of ethics for the regulated professions, an important economic or financial interest of the State or the protection of the data subject or the rights and freedoms of others, are not in contradiction with the standard contractual clauses. Some examples of such mandatory requirements which do not go beyond what is necessary in a democratic society are, inter alia, internationally recognised sanctions, tax-reporting requirements or anti-money-laundering reporting requirements.
[^13]: This requirement may be satisfied by the subprocessor co-signing the contract entered into between the data exporter and the data importer under this Decision.
---
## Annex 2: Security measures [archived 2026-06-11]
:::warning Archived version
This is the **11 June 2026** version of this document, preserved for historical reference. It is no longer maintained. For the current version, see [the live document](/legal/annex-security-measures).
:::
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/archive/data-processing-agreement/2022-08-24).
:::
TECHNICAL AND ORGANISATIONAL MEASURES TO ENSURE THE SECURITY OF THE DATA
This Annex forms part of the DPA.Holistics currently observes the security practices described in this Annex 2.
Notwithstanding any provision to the contrary otherwise agreed to by Customer, Holistics may modify or update these practices at its discretion provided that such modification and update does not result in a material degradation in the protection offered by these practices.
All capitalized terms not otherwise defined herein shall have the meanings as set forth in the Holistics Terms of Service (Terms) stated at https://www.holistics.io/terms/.
**a) Access Control**
i) Preventing Unauthorized Product Access
**Outsourced processing** : Holistics hosts its Service with Digital Ocean, a data center provider based in Germany, United States, and Singapore. Additionally, Holistics maintains contractual relationships with vendors in order to provide the Service. Holistics relies on contractual agreements, privacy policies, and vendor compliance programs in order to assure the protection of data processed or stored by these vendors.
**Physical and environmental security** : Our servers for the Subscription Service are hosted with Digital Ocean and Amazon Web Services. Our data centres are based in the United States, Germany, and Singapore.
**Authentication** : Customers who interact with Holistics software must authenticate before accessing non-public customer data.
**Authorization** : Customer data is stored in multi-tenant storage systems accessible to Customers via only application user interfaces and application programming interfaces. Customers are not allowed direct access to the underlying application infrastructure.
The authorization model in each of Holistics' products is designed to ensure that only the appropriately assigned individuals can access relevant features, views, and customization options. Authorization to data sets is performed through validating the user's permissions against the attributes associated with each data set.
**Application Programming Interface (API) access**: Holistics allows the customer to expose public product APIs using an API key.
ii) Preventing Unauthorized Product Use
Holistics implements industry standard access controls and detection capabilities for the internal networks that support its products.
**Access controls** : Network access control mechanisms are designed to prevent network traffic using unauthorized protocols from reaching the product infrastructure.
**Static code analysis:** Security reviews of code stored in Holistics' source code repositories is performed, checking for coding best practices and identifiable software flaws.
**Responsible Disclosure** : A Responsible Disclosure program invites and incentivizes independent security researchers to ethically discover and disclose security flaws. This widens the available opportunities to engage with the security community and improve the product defenses against sophisticated attacks.
iii) Limitations of Privilege & Authorization Requirements
**Internal Data Access by personnel** :
Only authorized personnel are allowed access to the infrastructure provided are restricted to authorized personnel on the principle of least privilege.
SSH users use unique accounts to access production machines. Furthermore, the use of the root account is not used.
Access to sensitive systems and applications requires two factor authentication in the form of user ID, password, OTP and/or certificate
Holistics has established formal guidelines for passwords to govern the management and use of authentication mechanisms.All access is logged, and removed when appropriate.
Access to the corporate network, production machines, network devices, and support tools requires a unique ID.
**b) Transmission Control**
**In-transit** :
Holistics ensures that all connections to its web application from its users are encrypted.
Holistics uses configurations that ensure only approved networking ports and protocols are implemented, including firewalls.
Management has implemented tools to log network traffic into a system that allows monitoring and ad hoc queries.
**At-rest** :
Holistics encrypts Customer's database connection credentials and cached data stored at rest.
Access to sensitive systems and applications requires two factor authentication in the form of user ID, password, OTP and/or certificate
Only authorized users with the correct SSH key may gain access to production machines
**c) Input Control**
**Detection** : Holistics designed its infrastructure to log extensive information about the system behavior, traffic received, system authentication, and other application requests. Internal systems aggregated log data and alert appropriate employees of malicious, unintended, or anomalous activities. Holistics personnel, including security, operations, and support personnel, are responsive to known incidents.
**Response and tracking** : Holistics maintains a record of known security incidents that includes description, dates and times of relevant activities, and incident disposition. Suspected and confirmed security incidents are investigated by security, operations, or support personnel; and appropriate resolution steps are identified and documented. For any confirmed incidents, Holistics will take appropriate steps to minimize product and Customer damage or unauthorized disclosure.
**Communication** : If Holistics becomes aware of unlawful access to Customer data stored within its products, Holistics will:
1. Notify the affected Customers of the incident;
2. Provide a description of the steps Holistics is taking to resolve the incident; and
3. Provide status updates to the Customer contact, as Holistics deems necessary.
Notification(s) of incidents, if any, will be delivered to one or more of the Customer's contacts in a form Holistics selects, which may include via email or telephone.
**d) Data Storage**
Unlike most business intelligence software, Holistics Software does not store any physical records of Customer Data permanently. Instead Holistics generates SQL that directly queries the database and visualizes the records in the browser.
**Terminating Customers** : Holistics Metadata in active (i.e primary) databases is purged 180 days after a customer terminates all agreements for such products with Holistics, or upon a customer's written request. Information stored in backups, replicas, and snapshots is not automatically purged, but instead ages out of the system as part of the data lifecycle. Holistics reserves the right to alter data purging periods in order to address technical, compliance, or statutory requirements.
**e) Availability Control**
**Infrastructure availability** : The data center providers use commercially reasonable efforts to ensure a minimum of 99.9% uptime. The providers maintain a minimum of N+1 redundancy to power, network, and HVAC services.
**Fault tolerance** : Backup and replication strategies are designed to ensure redundancy and fail-over protections during a significant processing failure.
Holistics' products are designed to ensure redundancy and seamless failover. The server instances that support the products are also architected with a goal to prevent single points of failure. This design assists Holistics operations in maintaining and updating the product applications and backend while limiting downtime.
**f) Event Logging**
Holistics has implemented tools to
- collect and store server logs in a central location. The system can be queried in an ad hoc fashion by authorized users
- log application state into a system that allows monitoring and ad hoc queries.
- monitor Holistics Software SQL databases and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- monitor Holistics Software load balancers and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy
- monitor Holistics Software messaging queues and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- log network traffic into a system that allows monitoring and ad hoc queries.
- Monitor Holistics Software servers and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- Retain log entries **for at least 12 months**
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
---
## Annex 3: List of Holistics sub-processors [archived 2026-06-11]
:::warning Archived version
This is the **11 June 2026** version of this document, preserved for historical reference. It is no longer maintained. For the current version, see [the live document](/legal/annex-sub-processors).
:::
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/archive/data-processing-agreement/2022-08-24).
:::
"Sub-Processors for Customer Database" refers to the service providers that are used by Holistics as described in the DPA ([https://go.holistics.io/dpa](https://go.holistics.io/dpa)).
### Sub-processors For Customer Database
***1. Amazon Web Services, Inc.***
**Entity Headquarter Country**: USA
**Purpose**: Amazon S3, Redshift, RDS and/or other AWS services
**Locations of Servers**: USA, Germany, Singapore
**GDPR**: [https://aws.amazon.com/compliance/gdpr-center/](https://aws.amazon.com/compliance/gdpr-center/)
***2. DigitalOcean, Inc***
**Entity Headquarter Country**: USA
**Locations of Servers**: USA, Germany, Singapore
**Purpose**: Infrastructure Hosting
**GDPR**: [https://www.digitalocean.com/security/gdpr/](https://www.digitalocean.com/security/gdpr/)
***3. Mailgun, Inc***
**Entity Headquarter Country**: USA
**Locations of Servers**: USA, Germany, Singapore
**Purpose**: Transactional Emails
**GDPR**: [https://www.mailgun.com/gdpr](https://www.mailgun.com/gdpr)
### Sub-processors For Holistics Usage and Metadata
The below sub-processors will process users and usage data from (or added by) Holistics users who are direct customers of Holistics (Customer End Users).
***1. Google, Inc.***
**Entity Country** : USA
**Purpose** : Google Cloud Platform, Google BigQuery, Google Sheets
**GDPR** : [https://cloud.google.com/security/gdpr/](https://cloud.google.com/security/gdpr/)
**Email** : [redacted]
***2. AppCues***
**Entity Country** : USA
**Purpose** : Onboarding Flow (For New Trial Users or New Releases)
**GDPR** : https://trust.appcues.com
**Email** : [redacted]
***3. FullStory***
**Entity Country** : USA
**Purpose** : Facilitate Onboarding Study
**Remarks** : Assist for new free trial users (only) onboarding. Turned off for European residents and active paying customers.
**GDPR** : [https://help.fullstory.com/general-data-protection-regulation/gdpr](https://help.fullstory.com/hc/en-us/sections/360003790713-General-Data-Protection-Regulation-GDPR-)
**Email** : [redacted]
***4. HubSpot***
**Entity Country** : USA
**Purpose** : Marketing Platform
**GDPR** : [https://www.hubspot.com/data-privacy/gdpr](https://www.hubspot.com/data-privacy/gdpr)
**Email** : [redacted]
***5. JotForm***
**Entity Country** : USA
**Purpose** : Survey Tool
**GDPR** : [https://www.jotform.com/gdpr-compliance/](https://www.jotform.com/gdpr-compliance/)
**Email** : [redacted]
***6. Pipedrive***
**Entity Country** : USA
**Purpose** : Sales Management
**GDPR** : [https://support.pipedrive.com/hc/en-us/articles/360000335129-Pipedrive-and-GDPR](https://support.pipedrive.com/hc/en-us/articles/360000335129-Pipedrive-and-GDPR)
**Email** : [redacted]
***7. Slack***
**Entity Country** : USA
**Purpose** : Holistics Integrations
**GDPR** : [https://slack.com/gdpr](https://slack.com/gdpr)
***8. Zapier***
**Entity Country** : USA
**Purpose** : Task Automation
**GDPR** : https://zapier.com/legal/data-privacy
**Email** : [redacted]
***9. Zendesk***
**Applications Used** : Chat, Support
**Entity Country** : USA
**Purpose** : Product Support and Helpdesk
**GDPR:** [https://help.zendesk.com/hc/en-us/articles/360000586767-Complying-with-GDPR-in-Zendesk-products](https://help.zendesk.com/hc/en-us/articles/360000586767-Complying-with-GDPR-in-Zendesk-products)
***10. Notion***
**Entity Country** : USA
**Purpose** : Company KnowledgeBase of Customer End Users
**GDPR** : https://www.notion.so/help/gdpr-at-notion
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
---
## Annex 1: Subject matter and details of data processing [archived 2026-06-11]
:::warning Archived version
This is the **11 June 2026** version of this document, preserved for historical reference. It is no longer maintained. For the current version, see [the live document](/legal/annex-subject-matter).
:::
:::info Notes
This is a part of our [Data Processing Agreement (DPA)](/legal/archive/data-processing-agreement/2022-08-24).
:::
**A. LIST OF PARTIES**
**Data Exporter**
The data exporter is the Customer, a non-Holistics entity, as defined in the Holistics Terms of Service (Terms) at [https://www.holistics.io/terms/](https://www.holistics.io/terms/).
**Company Name** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Company Address** :
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Person Name** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Person Position** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Position Email** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Customer Role (Check where it applies):**
- EU Controller - EU SCC Module 2 Applies (Controller to Processor) - Annex 4A
- EU Controller - EU SCC Module 2 Applies (Processor to Processor) - Annex 4B
- UK Controller - UK SCC Applies (Processor to Processor) - Annex 5
**Activities relevant to the data transferred under these Clauses:**
Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms")
**Data importer**
**Name** : Holistics Software Pte Ltd
**Address** : 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545
**Role** : Processor
**Contact person's name, position and contact details** :
Thanh Dinh Khac, Chief Engineer,
Holistics Software Pte Ltd
Email: [redactted]
**Activities relevant to the data transferred under these Clauses** : Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms")
**B. DESCRIPTION OF TRANSFER**
**Data subjects**
The personal data transferred concern the following categories of data subjects in two main categories
1. **Customer End Users of the Holistics Subscription Service** , mainly the employees of the Data Exporter, and other individuals who have been invited to access the Holistics Subscription Service in their customer account. This also includes users who have submitted their contact details through the Holistics website.
2. **Data subjects whose data is stored in the Exporter's database** connected to the Holistics application servers that may contain Personal Data.
**Categories of data**
1. **Holistics Metadata and Usage Data (From Customer End Users)**
The personal data transferred concern personal data, software license checks, audit trails, website usage information (URLs accessed, time of access, browser type), email data, metadata on reports and dashboards, data source schemas and other electronic data submitted, stored, sent, or received by users of the Subscription Service
2. **Temporary Cached Query Results from the Customer (Exporter)'s database**
Once the Customer's database is connected to the Holistics server, the Holistics cache temporarily retains data from the database that is fetched in response to a users' report queries. The Exporter can reduce the amount of time that query results are held in cache (minimum of 10 minutes), or to turn off the cache completely.
When a dashboard widget is exported into Excel/CSV file, the file will also be temporarily stored in Holistics' file storage system
**Sensitive Data Transferred and Applied Restrictions or Safeguards**
The parties do not anticipate the transfer of sensitive data. In the event sensitive data is stored in Customer's Database, Customer has flexibility to restrict or isolate sensitive data from the database user credential account that is used to connect to Holistics Software.
**Frequency of the transfer**
Adhoc. when a dashboard loads either from user access or from a scheduled job configured by the customer.
**Purpose of the transfer and further processing**
Holistics will process data for the purposes of providing the Subscription Services to Customer in accordance with the Holistics Terms of Service ("Terms").
**Period for which Data will be retained**
Temporary Cached Query Results will be stored for a minimum of 10 minutes (or higher) from the time the dashboard is first accessed.
Exported files (Excel/CSV downloads) are stored for up to 24 hours before they expire automatically.
Holistics Metadata and Usage Data (From Customer End Users) will be removed after 180 days after the Term expires, or earlier upon request by Customer..
**Competent Supervisory Authority**
For the purposes of the Standard Contractual Clauses, the supervisory authority that shall act as competent supervisory authority is either
1. **Where Customer is established in an EU Member State** , the supervisory authority responsible for ensuring Customer's compliance with the GDPR;
2. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR and has appointed a representative, the supervisory authority of the **EU Member State in which Customer's representative is established** ; or
3. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR without having to appoint a representative, the supervisory authority of the EU Member State in **which the Data Subjects are predominantly located** in relation to Data Processed that is subject to the UK GDPR or Swiss DPA, the competent supervisory authority is the UK Information Commissioner or the Swiss Federal Data Protection and Information Commissioner (as applicable).
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
---
## Data processing agreement (DPA) (effective 13 December 2021)
:::warning Superseded version
This is the **13 December 2021** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Data processing agreement (DPA)](/legal/dpa).
:::
:::tip Where to sign this document
Sign the Holistics Data Processing Agreement at: https://r.holistics.io/signdpa
:::
## Holistics Data Processing Agreement
*Last Updated: 13 Dec 2021*
**Definitions**
"California Personal Information" means Personal Data that is subject to the protection of the CCPA.
"CCPA" means California Civil Code Sec. 1798.100 et seq. (also known as the California Consumer Privacy Act of 2018).
"Consumer", "Business", "Sell" and "Service Provider" shall have the meanings given to them in the CCPA.
"Customer" refers to the Customer on a paid subscription plan with Holistics as described in the Terms, and all of its Affiliates.
"Customer Data" or "Customer Database" refers to all data residing in the Customer's database(s) and data source(s) connected to Holistics by Customer.
Customer End Users means the employees of the Customer who have been invited to access the Holistics Subscription Service in their customer account, or in contact with Holistics.
"Data Protection Laws" means all applicable worldwide legislation relating to data protection and privacy which applies to the respective party in the role of Processing Personal Data in question under the Agreement, including without limitation European Data Protection Laws (EU and UK GDPR), the US CCPA, the Swiss FDPA, the Singapore PDPA, and the data protection and privacy laws of Australia; in each case as amended, repealed, consolidated or replaced from time to time.
"Data Subject" means the individual to whom "Personal Data" relates.
"Database Metadata" refers to the following categories of metadata from the customers' database which includes broadly (but not limited to)
User credentials of data source(s). These credentials are applied with the necessary security encryption before storing them in Holistics database
The metadata (example the names of schemas, tables, fields, model relationships description) of the database table), excluding the physical data record entries
The metadata of definitions of objects made created within the Holistics application (dashboards, data sets, data models, automated schedules)
Any other metadata that may be added in from time to time.
"Europe" means the European Union, the European Economic Area and/or their member states, Switzerland and the United Kingdom.
"European Data" means Personal Data that is subject to the protection of European Data Protection Laws.
"European Data Protection Laws" means data protection laws applicable in Europe, including:
Regulation 2016/679 of the European Parliament and of the Council on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation) ("GDPR");
Directive 2002/58/EC concerning the processing of personal data and the protection of privacy in the electronic communications sector; and (iii) applicable national implementations of (i) and (ii); or
GDPR as it forms parts of the United Kingdom domestic law by virtue of Section 3 of the European Union (Withdrawal) Act 2018 ("UK GDPR")
Swiss Federal Data Protection Act on 19 June 1992 and its Ordinance (“Swiss DPA”); in each case, as may be amended, superseded or replaced.
"Instruction" means the written instruction, issued by Customer to Holistics, and directing the same to perform a specific action with regard to the Customer Database (including, but not limited to, depersonalising, blocking, deletion, making available). Instructions shall initially be specified in the Terms and may, from time to time thereafter, be amended, amplified or replaced by Customer in separate written instructions (individual instructions).
"PDPA" refers to the Personal Data Protection Act 2012 legislated in Singapore.
"Personal Data" means the personal data contained within the Customer Database, including any special categories of personal data defined under the Data Protection Laws of each jurisdiction, in each case that is processed by Holistics under the Terms.
"Personal Data Breach" means a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, Personal Data transmitted, stored or otherwise Processed by Holistics and/or its Sub-Processors in connection with the provision of the Subscription Services. "Personal Data Breach" shall not include unsuccessful attempts or activities that do not compromise the security of Personal Data, including unsuccessful log-in attempts, pings, port scans, denial of service attacks, and other network attacks on firewalls or networked systems.
"Process" or "Processing" means any operation or set of operations which is performed on Personal Data, encompassing the collection, recording, organization, structuring, storage, adaptation or alteration, retrieval, consultation, use, disclosure by transmission, dissemination or otherwise making available, alignment or combination, restriction or erasure of Personal Data.
"SCCs" means the Customer SCCs and/or SCCs as applicable.
Module 2: From a controller to a processor (C2P)
Module 3: From a processor to a processor (P2P)
"Sub-Processor" means any Processor engaged by Holistics, or Affiliates to assist in fulfilling the obligations with respect to the provision of the Subscription Services under the Agreement. Sub-Processors may include third parties or Affiliates but will exclude any Holistics employee or consultant.
"Temporary Cached Query Results" refer to all results provided to Customer, Customer End Users, or for System Consumption (APIs) for queries executed against Customer Database via Holistics for technical and performance reasons. These results are cached temporarily and will automatically expire after a specific time (minimum 10 minutes) after a unique SQL query is executed from the Customer Database.
"Terms" refers to the Terms of Service at https://www.holistics.io/terms/
### Introduction
This Data Processing Agreement ("DPA") reflects the parties' agreement with respect to the terms governing the Processing of data in the Customer Database under the Holistics Customer Terms of Service ("Terms"), and supersedes any previously signed DPA on an earlier date.
The DPA is an addon to, and forms an integral part of the Terms. It is effective upon its incorporation into the Terms, an online self-service purchase, or an Order or an executed amendment to the Agreement.
The terms "personal data", "data subject", "processing", "controller" and "processor" used in this DPA have the meanings given in the GDPR irrespective of whether European Data Protection Law or Non-European Data Protection Law applies.
The terms "Personal Data", "Customer Data", and "Customer Database" may be used interchangeably in this DPA.
This DPA shall follow the term of the Terms, including but not restricted to the Terms clauses
"Account Information from Third Party Providers"
"Limitation of Liability" and
"Indemnification" clauses.
In case of any conflict or inconsistency with the Terms, this DPA will take precedence to the extent of such conflict or inconsistency
The duration of Processing shall be the same as the duration of the Terms and this DPA.
The clauses of this DPA shall follow the Terms. Definitions not otherwise defined above herein shall have the meaning as set forth in the Terms.
### Holistics' Responsibilities
Holistics will only Process Customer Database for the purposes described in this DPA or as otherwise agreed within the scope of the Customer's Instructions, except where and to the extent otherwise required by applicable law.
Holistics will only access or use Customer Database to provide the Services ordered by Customer and will not use it for any other Holistics products, services, advertising, or to resell the data.
Holistics is not responsible for compliance with any Data Protection Laws applicable to the Customer's industry that are not applicable to us.
Holistics shall email the customer if we become aware of a confirmed breach and also further
Take any such reasonably necessary measures and actions to remedy or mitigate the effects of the Breach and
Keep the Customer informed of all material developments in connection with the Breach.
Provide reasonable information and cooperation so that the Customer can fulfill any data breach reporting obligations it may have under (and in accordance with the timescales required by) the applicable Data Protection law.
If any such request, correspondence, enquiry or complaint is made directly to the Holistics, Holistics will promptly inform the Customer providing full details of the same.
Holistics will take the appropriate technical and organisational measures (listed in Annex 2) to adequately protect Customer Database against misuse and loss in accordance with the requirements of the applicable national data protection law. Such measures hereunder shall include, but not be limited to,
the prevention of unauthorised persons from gaining access to Customer Database (physical access control),
the prevention of Customer Database from being accessed without authorisation (logical access control),
ensuring that Customer Database cannot be read, copied, modified or deleted without authorisation during electronic transmission and Holistics Software instance. (data transfer control),
Have a reasonable audit trail system in place to document whether and by whom information on Customer Database has been entered into, modified in, or removed from Customer Database (entry control),
ensuring that data from Customer Database are processed solely in accordance with the Instructions (control of instructions),
persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality,
Holistics Data Protection Team will provide prompt and reasonable assistance with any Customer queries related to processing of Customer Personal Data under the Agreement and can be contacted at [redacted].
### Customer Responsibilities
Customer is responsible for complying with all applicable Data Protection Laws with respect to its Processing of Personal Data in the Customer Database connected to Holistics.
Customer shall retain title to their Customer Database connected to the Holistics Software instance and take technical safeguards to provision (and not over-provision) the appropriate level of data source connection for the user credentials supplied to Holistics.
Customer shall be solely responsible for
the accuracy, quality, and legality of Customer Database and the means in which Personal Data is acquired;
complying with all necessary transparency and lawfulness requirements under applicable Data Protection Laws for the collection and use of the Personal Data, including obtaining any necessary consents and authorizations (particularly for use by Customer for marketing purposes);
complying with the statutory requirements relating to data protection, in particular regarding safeguards against unauthorized access to Customer Database from Holistics software systems.
Customer shall inform Holistics without undue delay and comprehensively about any errors or irregularities related to statutory provisions on the Processing of Customer Database detected during a verification of the results of such Processing.
Customer is responsible for security relating to its environment and databases and security relating its configuration of the Software. This includes implementing and managing procedural, technical, and administrative safeguards on its software and networks sufficient to:
ensure the confidentiality, security, integrity, and privacy of Customer Database in transit, at rest, and in storage;
protect against any anticipated threats or hazards to the security and integrity of Customer Database; and
protect against any unauthorized processing, loss, use, disclosure or acquisition of or access to Customer Database.
Customer will minimize the sharing of Personal Data of Data Subjects in the support tickets and emails information sent to Holistics.
If such Personal Data needs to be included for troubleshooting, the Customer will deliberately add specific Instructions to handle such email communications.
For the avoidance of doubt, emails sent by the Customer with generic company email content confidentiality boilerplates appended by default will not be classified as confidential information.
Notwithstanding any other provision of this DPA, the Terms or any other agreement related to the Software and Services, Holistics has no obligations or liability as to any breach or loss resulting from:
The Customer's environment, databases, systems or software, or
The Customer's security configuration or administration of the Software.
Customer is solely responsible for provisioning Users on the Software, including:
methods of authenticating Users (such as industry-standard secure username/password policies, two-factor authentication etc);
Restricting access by User or group, and from the database level down to the row or column level;
Managing admin privileges;
deauthorizing personnel who no longer need access to the Software;
setting up any API usage in a secure way; and
regularly auditing any public access links Users create and restricting the permission to create public links, as necessary.
Customer is responsible to remove the network connection between Customer Database and the Holistics Software Instance should they terminate the Subscription Service.
### Customer Database Sub-Processors
Customer consents to Holistics engaging affiliates and third party sub-processors to process data in Customer Database for the purpose as described in the Terms.
Holistics will maintain an up-to-date list of its sub-processors. For avoidance of doubt, the above consent constitutes Customer's prior written consent to the sub-Processing by Holistics (Annex 3)
Holistics will impose data protection terms on any sub-processor it appoints as required to protect Customer Data to the standard required by the Data Protection Laws.
If Holistics intends to instruct sub-Processors other than the companies listed in Annex 3, Holistics will notify the Customer thereof in writing (email to the email address(es) on record in Processor's account information for Customer is sufficient) and will give the Customer the opportunity to object to the engagement of the new sub-Processors within 30 days after being notified.
The objection, if raised, must be based on reasonable grounds (e.g. if the Customer proves that significant risks for the protection of its Customer Data exist at the sub-Processor).
In such an event, Holistics will either not appoint or replace the sub-processor or, if this is not possible, Customer may suspend or terminate the Terms (without prejudice to any fees incurred by Customer prior to suspension or termination).
**Data Transfers**
Customer acknowledges and agrees that Holistics may access and process Customer Data on a global basis as necessary to provide the Subscription Service in accordance with the Agreement, and in particular that Customer Data may be transferred to the data centre location(s) that Holistics operates in.
Holistics may store and process (i) Holistics Metadata and Usage Data and (ii) Temporary Cached Query Results anywhere Holistics or its Sub-processors maintain facilities, subject to Sections on \<Additional Provisions for European Data\>, \<Additional Provisions for California Personal Information\>, or other jurisdictions where Holistics operates in.
The physical data records residing in Customer Database will not be stored permanently by Holistics application servers outside of the purpose set in the Terms.
Temporary Cached Query Results needed to visualize the dashboard data will be temporarily stored in Holistics, and will automatically expire after a specific time duration.
Wherever Personal Data is transferred outside its country of origin, each party will ensure such transfers are made in compliance with the requirements of Data Protection Laws.
### Provisions Specific for European Data
The parties acknowledge and agree that European Data Protection Law will apply to the processing of Customer Data if
the processing is carried out in the context of the activities of an establishment of Customer in the territory of the EEA or the UK; and/or
Customer Personal Data is personal data relating to data subjects who are in the EEA or the UK and the processing relates to the offering to them of goods or services in the EEA or the UK, or the monitoring of their behavior in the EEA or the UK.
"Controller" means the natural or legal person, public authority, agency or other body which, alone or jointly with others, determines the purposes and means of the Processing of Personal Data
"Processor" means a natural or legal person, public authority, agency or other body which Processes Personal Data on behalf of the Controller.
Relationship between Customer and Holistics
Holistics is the Processor of the Customer Database for the purposes described in the Terms. "Processor"means a natural or legal person, public authority, agency or other body which Processes Personal Data on behalf of the Controller.
Customer is the Controller of data (which may include Personal Data and Data Subjects) stored in the Customer Database.
Holistics and the Customer shall be separately responsible for conforming with such statutory data protection regulations as are applicable to them
Legacy MCCs: The SCCs will, as of the Transition Date, supersede and terminate any Model Contract Clauses approved under Directive 95/46/EC and previously entered into by Customer and Holistics. The Transition Date means October 27, 2021 if (a) Customer's billing address is outside EMEA, and (b) the processing of Customer Personal Data is subject to European Data Protection Law. If both (a) and (b) do not apply, the Transition Date is September 27, 2021.
Data Protection Impact Assessments and Consultation with Supervisory Authorities: Holistics will (taking into account the nature of the processing and the information available to Holistics) assist Customer in ensuring compliance with its (or, where Customer is a processor, the relevant controller's) obligations under Articles 35 and 36 of the GDPR, by:
Providing and updating our public documentation on technical security measures (https://docs.holistics.io/docs/data-security)
Providing public documentation on how Holistics caching and job queuing mechanism work (https://docs.holistics.io/docs/data-caching)
Providing the Security Measures (Annex 2) contained in the Agreement including these Terms; and
if the above subsections are insufficient for Customer (or the relevant controller) to comply with such obligations, upon Customer's request, providing Customer with additional reasonable cooperation and assistance.
Transfer Mechanism for Data Transfers:
Permitted Transfers. The parties acknowledge that European Data Protection Law does not require SCCs or an Alternative Transfer Solution in order for Customer Personal Data to be processed in or transferred to an Adequate Country ("Permitted Transfers").
Restricted Transfers. If the processing of Customer Personal Data is not processed in an Adequate Country, and European Data Protection Law applies to those transfers, then
The EU SCCs (EU Controller-to-Processor) will apply with respect to Restricted Transfers between Customer and Holistics that are subject to the EU GDPR and/or the Swiss FDPA; and
the UK SCCs (UK Controller-to-Processor) will apply (regardless of whether Customer is a controller and/or processor) with respect to Restricted Transfers between Customer and Holistics that are subject to the UK GDPR.
Holistics agrees to abide by and process European Data in compliance with the Standard Contractual Clauses.
Although Holistics does not rely on the Singapore Personal Data Protection Act 2012 ("PDPA") as a legal basis for transfers of Personal Data, Holistics will inform Customer if it is unable to comply with this requirement if any conflicts arise.
The parties agree that for the purposes of the Standard Contractual Clauses,
Holistics will be the "data importer" and Customer will be the "data exporter" (on behalf of itself and Permitted Affiliates);
the Annexes of the Standard Contractual Clauses shall be populated with the relevant information set out in Annex 1 and Annex 2 of this DPA;
if and to the extent the Standard Contractual Clauses conflict with any provision of this DPA, the Standard Contractual Clauses will prevail to the extent of such conflict.
To extent that and for so long as the Standard Contractual Clauses as implemented in accordance with this DPA cannot be relied on by the parties to lawfully transfer Personal Data in compliance with the GDPR, the applicable standard data protection clauses issued, adopted or permitted under the GDPR shall be incorporated by reference, and the annexes, appendices or tables of such clauses shall be deemed populated with the relevant information set out in Annex 1 and Annex 2 of this DPA.
Demonstration of Compliance
Holistics will make all information reasonably necessary to demonstrate compliance with this DPA available to Customer and allow for and contribute to audits, including inspections conducted by or an auditor appointed by Customer in order to assess compliance with this DPA.
Customer acknowledges and agree to exercise audit rights under this DPA and Clause 8 of the Standard Contractual Clauses by instructing Holistics to comply with the audit measures described in this 'Demonstration of Compliance' section.
Customer acknowledges that the Subscription Service is hosted by our data center partners (listed in our sub-processors) who maintain independently validated security programs.
Holistics may charge a fee (based on Holistics' reasonable costs) for any audit under Demonstration of Compliance. Holistics will provide the Customer with further details of any applicable fee, and the basis of its calculation, in advance of any such audit. Customer will be responsible for any fees charged by any auditor appointed by Customer to execute any such audit.
Holistics may object in writing to an auditor appointed by Customer to conduct any audit under Demonstration of Compliance if the auditor is, in Holistics' reasonable opinion, not suitably qualified or independent, a competitor of Holistics, or otherwise manifestly unsuitable. Any such objection by Holistics will require the Customer to appoint another auditor or conduct the audit itself.
Processing Records: Holistics will keep appropriate documentation of its processing activities. To the extent the GDPR requires Holistics to collect and maintain records of certain information relating to Customer, Customer will, where requested, supply such information to Holistics and keep it accurate and up-to-date. Holistics may make any such information available to the Supervisory Authorities if required by the GDPR.
No Modification of SCCs. Nothing in the Agreement (including these Terms) is intended to modify or contradict any SCCs or prejudice the fundamental rights or freedoms of data subjects under European Data Protection Law.
### Provisions Specific for California Personal Information
This section will apply only with respect to California Personal Information residing in Customer Database.
When processing California Personal Information in accordance with Customer's Instructions, the parties acknowledge and agree that Customer is a Business and Holistics is a Service Provider for the purposes of the CCPA.
Both parties agree that Holistics will Process California Personal Information as a Service Provider strictly for the purpose of performing the Subscription Services or as otherwise permitted by the CCPA, including as described in our Terms.
### Limitation of Liability
Each party's liability, taken together in the aggregate, arising out of or related to this DPA, and all DPAs between Customer and Holistics, whether in contract, tort or under any other theory of liability, is subject to the 'Limitation of Liability' section of the Terms, and any reference in such section to the liability of a party means the aggregate liability of that party under the Agreement and all DPAs together.
For the avoidance of doubt, Holistics' total liability for all claims from the Customer arising out of or related to the Agreement and each DPA shall apply in the aggregate for all claims under both the Agreement and all DPAs established under the Agreement by the Customer.
### Governing Law and Disputes
This DPA will be governed by and construed in accordance with the laws of the Singapore, unless otherwise required by
EU Data Protection Law, in which case this DPA will be governed by the laws of the Member State in which the Customer is established.
CCPA, in which case this DPA will be governed by the laws of California, USA.
the Data Protection Laws of each jurisdiction the Customer operates in
If Holistics becomes aware that Customer Data cannot be processed in accordance with the Customer's Instructions due to a legal requirement under any applicable law, Holistics will
promptly notify Customer that legal requirement to the extent permitted by the applicable law; and
where necessary, cease all Processing (other than merely storing and maintaining the security of the affected Customer Data) until such time as the Customer issues new Instructions with which Holistics is able to comply. If this provision is invoked, Holistics will not be liable to the Customer under the Agreement for any failure to perform the applicable Subscription Services until such time as Customer issues new lawful Instructions with regard to the Processing.
Arb-Med-Arb: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore.
The Tribunal shall consist of one (1) arbitrator(s)
The language of the arbitration shall be English
**Included Core Documents** :
- This Data Protection Agreement (DPA), as defined in https://r.holistics.io/dpa
- Holistics Terms of Service (Terms), as defined in [https://holistics.io/terms](https://holistics.io/terms)
- Annex 1: Subject Matter and Details of Data Processing
- Annex 2: Security Measures (Technical And Organisational Measures To Ensure The Security Of The Data)
- Annex 3: List of Holistics Sub-Processors
Selective Annex(es) where applicable to Customer
- Annex 4: EU SCC Module 2 (Controller to Processor)
- Annex 5: UK SCC (Controller to Processor)
| Signature: | Signature: |
|----------------------------|----------------|
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 1: Subject Matter and Details of Data Processing
**A. LIST OF PARTIES**
**Data Exporter**
The data exporter is the Customer, a non-Holistics entity, as defined in the Holistics Terms of Service (Terms) at [https://www.holistics.io/terms/](https://www.holistics.io/terms/).
**Company Name** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Company Address** :
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Person Name** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Person Position** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Position Email** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Customer Role (Check where it applies):**
- 𐄂 EU Controller - EU SCC Module 2 Applies (Controller to Processor) - Annex 4
- 𐄂 UK Controller - UK SCC Applies (Processor to Processor) - Annex 5
**Activities relevant to the data transferred under these Clauses:**
Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms")
**Data importer**
**Name** : Holistics Software Pte Ltd
**Address** : 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545
**Role** : Processor
**Contact person's name, position and contact details** :
Thanh Dinh Khac, Chief Engineer,
Holistics Software Pte Ltd
Email: [redactted]
**Activities relevant to the data transferred under these Clauses** : Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms")
**B. DESCRIPTION OF TRANSFER**
**Data subjects**
The personal data transferred concern the following categories of data subjects in two main categories
1. **Customer End Users of the Holistics Subscription Service** , mainly the employees of the Data Exporter, and other individuals who have been invited to access the Holistics Subscription Service in their customer account. This also includes users who have submitted their contact details through the Holistics website.
2. **Data subjects whose data is stored in the Exporter's database** connected to the Holistics application servers that may contain Personal Data.
**Categories of data**
1. **Holistics Metadata and Usage Data (From Customer End Users)**
The personal data transferred concern personal data, software license checks, audit trails, website usage information (URLs accessed, time of access, browser type), email data, metadata on reports and dashboards, data source schemas and other electronic data submitted, stored, sent, or received by users of the Subscription Service
2. **Temporary Cached Query Results from the Customer (Exporter)'s database**
Once the Customer's database is connected to the Holistics server, the Holistics cache temporarily retains data from the database that is fetched in response to a users' report queries. The Exporter can reduce the amount of time that query results are held in cache (minimum of 10 minutes), or to turn off the cache completely.
When a dashboard widget is exported into Excel/CSV file, the file will also be temporarily stored in Holistics' file storage system
**Sensitive Data Transferred and Applied Restrictions or Safeguards**
The parties do not anticipate the transfer of sensitive data. In the event sensitive data is stored in Customer's Database, Customer has flexibility to restrict or isolate sensitive data from the database user credential account that is used to connect to Holistics Software.
**Frequency of the transfer**
Adhoc. when a dashboard loads either from user access or from a scheduled job configured by the customer.
**Purpose of the transfer and further processing**
Holistics will process data for the purposes of providing the Subscription Services to Customer in accordance with the Holistics Terms of Service ("Terms").
**Period for which Data will be retained**
Temporary Cached Query Results will be stored for a minimum of 10 minutes (or higher) from the time the dashboard is first accessed.
Exported files (Excel/CSV downloads) are stored for up to 24 hours before they expire automatically.
Holistics Metadata and Usage Data (From Customer End Users) will be removed after 180 days after the Term expires, or earlier upon request by Customer..
**Competent Supervisory Authority**
For the purposes of the Standard Contractual Clauses, the supervisory authority that shall act as competent supervisory authority is either
1. **Where Customer is established in an EU Member State** , the supervisory authority responsible for ensuring Customer's compliance with the GDPR;
2. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR and has appointed a representative, the supervisory authority of the **EU Member State in which Customer's representative is established** ; or
3. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR without having to appoint a representative, the supervisory authority of the EU Member State in **which the Data Subjects are predominantly located** in relation to Data Processed that is subject to the UK GDPR or Swiss DPA, the competent supervisory authority is the UK Information Commissioner or the Swiss Federal Data Protection and Information Commissioner (as applicable).
| Signature: | Signature: |
|----------------------------|----------------|
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 2 - Security Measures
TECHNICAL AND ORGANISATIONAL MEASURES TO ENSURE THE SECURITY OF THE DATA
This Annex forms part of the DPA.Holistics currently observes the security practices described in this Annex 2.
Notwithstanding any provision to the contrary otherwise agreed to by Customer, Holistics may modify or update these practices at its discretion provided that such modification and update does not result in a material degradation in the protection offered by these practices.
All capitalized terms not otherwise defined herein shall have the meanings as set forth in the Holistics Terms of Service (Terms) stated at https://www.holistics.io/terms/.
**a) Access Control**
i) Preventing Unauthorized Product Access
**Outsourced processing** : Holistics hosts its Service with Digital Ocean, a data center provider based in Germany, United States, and Singapore. Additionally, Holistics maintains contractual relationships with vendors in order to provide the Service. Holistics relies on contractual agreements, privacy policies, and vendor compliance programs in order to assure the protection of data processed or stored by these vendors.
**Physical and environmental security** : Our servers for the Subscription Service are hosted with Digital Ocean and Amazon Web Services. Our data centres are based in the United States, Germany, and Singapore.
**Authentication** : Customers who interact with Holistics software must authenticate before accessing non-public customer data.
**Authorization** : Customer data is stored in multi-tenant storage systems accessible to Customers via only application user interfaces and application programming interfaces. Customers are not allowed direct access to the underlying application infrastructure.
The authorization model in each of Holistics' products is designed to ensure that only the appropriately assigned individuals can access relevant features, views, and customization options. Authorization to data sets is performed through validating the user's permissions against the attributes associated with each data set.
**Application Programming Interface (API) access**: Holistics allows the customer to expose public product APIs using an API key.
ii) Preventing Unauthorized Product Use
Holistics implements industry standard access controls and detection capabilities for the internal networks that support its products.
**Access controls** : Network access control mechanisms are designed to prevent network traffic using unauthorized protocols from reaching the product infrastructure.
**Static code analysis:** Security reviews of code stored in Holistics' source code repositories is performed, checking for coding best practices and identifiable software flaws.
**Responsible Disclosure** : A Responsible Disclosure program invites and incentivizes independent security researchers to ethically discover and disclose security flaws. This widens the available opportunities to engage with the security community and improve the product defenses against sophisticated attacks.
iii) Limitations of Privilege & Authorization Requirements
**Internal Data Access by personnel** :
Only authorized personnel are allowed access to the infrastructure provided are restricted to authorized personnel on the principle of least privilege.
SSH users use unique accounts to access production machines. Furthermore, the use of the root account is not used.
Access to sensitive systems and applications requires two factor authentication in the form of user ID, password, OTP and/or certificate
Holistics has established formal guidelines for passwords to govern the management and use of authentication mechanisms.All access is logged, and removed when appropriate.
Access to the corporate network, production machines, network devices, and support tools requires a unique ID.
**b) Transmission Control**
**In-transit** :
Holistics ensures that all connections to its web application from its users are encrypted.
Holistics uses configurations that ensure only approved networking ports and protocols are implemented, including firewalls.
Management has implemented tools to log network traffic into a system that allows monitoring and ad hoc queries.
**At-rest** :
Holistics encrypts Customer's database connection credentials and cached data stored at rest.
Access to sensitive systems and applications requires two factor authentication in the form of user ID, password, OTP and/or certificate
Only authorized users with the correct SSH key may gain access to production machines
**c) Input Control**
**Detection** : Holistics designed its infrastructure to log extensive information about the system behavior, traffic received, system authentication, and other application requests. Internal systems aggregated log data and alert appropriate employees of malicious, unintended, or anomalous activities. Holistics personnel, including security, operations, and support personnel, are responsive to known incidents.
**Response and tracking** : Holistics maintains a record of known security incidents that includes description, dates and times of relevant activities, and incident disposition. Suspected and confirmed security incidents are investigated by security, operations, or support personnel; and appropriate resolution steps are identified and documented. For any confirmed incidents, Holistics will take appropriate steps to minimize product and Customer damage or unauthorized disclosure.
**Communication** : If Holistics becomes aware of unlawful access to Customer data stored within its products, Holistics will:
1. Notify the affected Customers of the incident;
2. Provide a description of the steps Holistics is taking to resolve the incident; and
3. Provide status updates to the Customer contact, as Holistics deems necessary.
Notification(s) of incidents, if any, will be delivered to one or more of the Customer's contacts in a form Holistics selects, which may include via email or telephone.
**d) Data Storage**
Unlike most business intelligence software, Holistics Software does not store any physical records of Customer Data permanently. Instead Holistics generates SQL that directly queries the database and visualizes the records in the browser.
**Terminating Customers** : Holistics Metadata in active (i.e primary) databases is purged 180 days after a customer terminates all agreements for such products with Holistics, or upon a customer's written request. Information stored in backups, replicas, and snapshots is not automatically purged, but instead ages out of the system as part of the data lifecycle. Holistics reserves the right to alter data purging periods in order to address technical, compliance, or statutory requirements.
**e) Availability Control**
**Infrastructure availability** : The data center providers use commercially reasonable efforts to ensure a minimum of 99.9% uptime. The providers maintain a minimum of N+1 redundancy to power, network, and HVAC services.
**Fault tolerance** : Backup and replication strategies are designed to ensure redundancy and fail-over protections during a significant processing failure.
Holistics' products are designed to ensure redundancy and seamless failover. The server instances that support the products are also architected with a goal to prevent single points of failure. This design assists Holistics operations in maintaining and updating the product applications and backend while limiting downtime.
**f) Event Logging**
Holistics has implemented tools to
- collect and store server logs in a central location. The system can be queried in an ad hoc fashion by authorized users
- log application state into a system that allows monitoring and ad hoc queries.
- monitor Holistics Software SQL databases and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- monitor Holistics Software load balancers and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy
- monitor Holistics Software messaging queues and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- log network traffic into a system that allows monitoring and ad hoc queries.
- Monitor Holistics Software servers and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- Retain log entries **for at least 12 months**
| Signature: | Signature: |
|----------------------------|----------------|
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 3: List of Holistics Sub-Processors
"Sub-Processors for Customer Database" refers to the service providers that are used by Holistics as described in the DPA ([http://r.holistics.io/dpa](http://r.holistics.io/dpa)).
**Sub-processors For Customer Database**
**1. Amazon Web Services, Inc.**
**Entity Country** : USA
**Purpose** : Amazon S3, Redshift, RDS and/or other AWS services
**GDPR** : [https://aws.amazon.com/compliance/gdpr-center/](https://aws.amazon.com/compliance/gdpr-center/)
**2. DigitalOcean, Inc**
**Entity Country** : USA
**Server Country** : Singapore
**Purpose** : Infrastructure Hosting
**GDPR** : [https://www.digitalocean.com/security/gdpr/](https://www.digitalocean.com/security/gdpr/)
**3. Mailgun, Inc**
**Entity Country** : USA
**Purpose** : Transactional Emails
**GDPR** : [https://www.mailgun.com/gdpr](https://www.mailgun.com/gdpr)
**Sub-processors For Holistics Usage and Metadata**
The below sub-processors will process users and usage data from (or added by) Holistics users who are direct customers of Holistics (Customer End Users).
**1. Google, Inc.**
**Entity Country** : USA
**Purpose** : Google Cloud Platform, Google BigQuery, Google Sheets
**GDPR** : [https://cloud.google.com/security/gdpr/](https://cloud.google.com/security/gdpr/)
**Email** : [redacted]
**2. AppCues**
**Entity Country** : USA
**Purpose** : Onboarding Flow (For New Trial Users or New Releases)
**GDPR** : [https://docs.appcues.com/article/318-gdpr-information](https://docs.appcues.com/article/318-gdpr-information)
**Email** : [redacted]
**3. FullStory**
**Entity Country** : USA
**Purpose** : Facilitate Onboarding Study
**Remarks** : Assist for new free trial users (only) onboarding. Turned off for European residents and active paying customers.
**GDPR** : [https://help.fullstory.com/general-data-protection-regulation/gdpr](https://help.fullstory.com/general-data-protection-regulation/gdpr)
**Email** : [redacted]
**4. HubSpot**
**Entity Country** : USA
**Purpose** : Marketing Platform
**GDPR** : [https://www.hubspot.com/data-privacy/gdpr](https://www.hubspot.com/data-privacy/gdpr)
**Email** : [redacted]
**5. JotForm**
**Entity Country** : USA
**Purpose** : Survey Tool
**GDPR** : [https://www.jotform.com/gdpr-compliance/](https://www.jotform.com/gdpr-compliance/)
**Email** : [redacted]
**6. Pipedrive**
**Entity Country** : USA
**Purpose** : Sales Management
**GDPR** : [https://support.pipedrive.com/hc/en-us/articles/360000335129-Pipedrive-and-GDPR](https://support.pipedrive.com/hc/en-us/articles/360000335129-Pipedrive-and-GDPR)
**Email** : [redacted]
**7. Slack**
**Entity Country** : USA
**Purpose** : Holistics Integrations
**GDPR** : [https://slack.com/gdpr](https://slack.com/gdpr)
**8. Zapier**
**Entity Country** : USA
**Purpose** : Task Automation
**GDPR** : [https://zapier.com/help/gdpr/](https://zapier.com/help/gdpr/)
**Email** : [redacted]
**9. Zendesk**
**Applications Used** : Chat, Support
**Entity Country** : USA
**Purpose** : Product Support and Helpdesk
**GDPR:** [https://help.zendesk.com/hc/en-us/articles/360000586767-Complying-with-GDPR-in-Zendesk-products](https://help.zendesk.com/hc/en-us/articles/360000586767-Complying-with-GDPR-in-Zendesk-products)
**10. Notion**
**Entity Country** : USA
**Purpose** : Company KnowledgeBase of Customer End Users
**GDPR** : https://www.notion.so/GDPR-at-Notion-8952f065d96f4c18abf22fc3c85e272e
| Signature: | Signature: |
|----------------------------|----------------|
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 4: EU SCC Module 2 (Controller to Processor)
**Note:** If there are actual differences between the official EU SCC
Module and this version below, the official EU SCC Module prevails.
___
STANDARD CONTRACTUAL CLAUSES
Controller to Processor
**SECTION I**
***Clause 1***
**Purpose and scope**
> \(a\) The purpose of these standard contractual clauses is to ensure
> compliance with the requirements of Regulation (EU) 2016/679 of the
> European Parliament and of the Council of 27 April 2016 on the
> protection of natural persons with regard to the processing of
> personal data and on the free movement of such data (General Data
> Protection Regulation) ([^1]) for the transfer of data to a third
> country.
>
> \(b\) The Parties:
>
> \(i\) the natural or legal person(s), public authority/ies, agency/ies
> or other body/ies (hereinafter 'entity/ies') transferring the personal
> data, as listed in Annex I.A (hereinafter each 'data exporter'), and
>
> \(ii\) the entity/ies in a third country receiving the personal data
> from the data exporter, directly or indirectly via another entity also
> Party to these Clauses, as listed in Annex I.A (hereinafter each 'data
> importer')
have agreed to these standard contractual clauses (hereinafter:
'Clauses').
> \(c\) These Clauses apply with respect to the transfer of personal
> data as specified in Annex I.B.
>
> \(d\) The Appendix to these Clauses containing the Annexes referred to
> therein forms an integral part of these Clauses.
***Clause 2***
**Effect and invariability of the Clauses**
> \(a\) These Clauses set out appropriate safeguards, including
> enforceable data subject rights and effective legal remedies, pursuant
> to Article 46(1) and Article 46(2)(c) of Regulation (EU) 2016/679 and,
> with respect to data transfers from controllers to processors and/or
> processors to processors, standard contractual clauses pursuant to
> Article 28(7) of Regulation (EU) 2016/679, provided they are not
> modified, except to select the appropriate Module(s) or to add or
> update information in the Appendix. This does not prevent the Parties
> from including the standard contractual clauses laid down in these
> Clauses in a wider contract and/or to add other clauses or additional
> safeguards, provided that they do not contradict, directly or
> indirectly, these Clauses or prejudice the fundamental rights or
> freedoms of data subjects.
>
> \(b\) These Clauses are without prejudice to obligations to which the
> data exporter is subject by virtue of Regulation (EU) 2016/679.
***Clause 3***
**Third-party beneficiaries**
> \(a\) Data subjects may invoke and enforce these Clauses, as
> third-party beneficiaries, against the data exporter and/or data
> importer, with the following exceptions:
\(i\) Clause 1, Clause 2, Clause 3, Clause 6, Clause 7;
> \(ii\) Clause 8.1(b), 8.9(a), (c), (d) and (e);
>
> \(iii\) Clause 9(a), (c), (d) and (e);
>
> \(iv\) Clause 12(a), (d) and (f);
\(v\) Clause 13;
\(vi\) Clause 15.1(c), (d) and (e);
\(vii\) Clause 16(e);
> \(viii\) Clause 18(a) and (b).
>
> \(b\) Paragraph (a) is without prejudice to rights of data subjects
> under Regulation (EU) 2016/679.
***Clause 4***
**Interpretation**
> \(a\) Where these Clauses use terms that are defined in Regulation
> (EU) 2016/679, those terms shall have the same meaning as in that
> Regulation.
>
> \(b\) These Clauses shall be read and interpreted in the light of the
> provisions of Regulation (EU) 2016/679.
>
> \(c\) These Clauses shall not be interpreted in a way that conflicts
> with rights and obligations provided for in Regulation (EU) 2016/679.
***Clause 5***
**Hierarchy**
In the event of a contradiction between these Clauses and the provisions
of related agreements between the Parties, existing at the time these
Clauses are agreed or entered into thereafter, these Clauses shall
prevail.
***Clause 6***
**Description of the transfer(s)**
The details of the transfer(s), and in particular the categories of
personal data that are transferred and the purpose(s) for which they are
transferred, are specified in Annex I.B.
***Clause 7 -- Optional***
**Docking clause**
> \(a\) An entity that is not a Party to these Clauses may, with the
> agreement of the Parties, accede to these Clauses at any time, either
> as a data exporter or as a data importer, by completing the Appendix
> and signing Annex I.A.
>
> \(b\) Once it has completed the Appendix and signed Annex I.A, the
> acceding entity shall become a Party to these Clauses and have the
> rights and obligations of a data exporter or data importer in
> accordance with its designation in Annex I.A.
>
> \(c\) The acceding entity shall have no rights or obligations arising
> under these Clauses from the period prior to becoming a Party.
**SECTION II -- OBLIGATIONS OF THE PARTIES**
***Clause 8***
**Data protection safeguards**
The data exporter warrants that it has used reasonable efforts to
determine that the data importer is able, through the implementation of
appropriate technical and organisational measures, to satisfy its
obligations under these Clauses.
**8.1 Instructions**
> \(a\) The data importer shall process the personal data only on
> documented instructions from the data exporter. The data exporter may
> give such instructions throughout the duration of the contract.
>
> \(b\) The data importer shall immediately inform the data exporter if
> it is unable to follow those instructions.
**8.2 Purpose limitation**
The data importer shall process the personal data only for the specific
purpose(s) of the transfer, as set out in Annex I.B, unless on further
instructions from the data exporter.
**8.3 Transparency**
On request, the data exporter shall make a copy of these Clauses,
including the Appendix as completed by the Parties, available to the
data subject free of charge. To the extent necessary to protect business
secrets or other confidential information, including the measures
described in Annex II and personal data, the data exporter may redact
part of the text of the Appendix to these Clauses prior to sharing a
copy, but shall provide a meaningful summary where the data subject
would otherwise not be able to understand the its content or exercise
his/her rights. On request, the Parties shall provide the data subject
with the reasons for the redactions, to the extent possible without
revealing the redacted information. This Clause is without prejudice to
the obligations of the data exporter under Articles 13 and 14 of
Regulation (EU) 2016/679.
**8.4 Accuracy**
If the data importer becomes aware that the personal data it has
received is inaccurate, or has become outdated, it shall inform the data
exporter without undue delay. In this case, the data importer shall
cooperate with the data exporter to erase or rectify the data.
**8.5 Duration of processing and erasure or return of data**
Processing by the data importer shall only take place for the duration
specified in Annex I.B. After the end of the provision of the processing
services, the data importer shall, at the choice of the data exporter,
delete all personal data processed on behalf of the data exporter and
certify to the data exporter that it has done so, or return to the data
exporter all personal data processed on its behalf and delete existing
copies. Until the data is deleted or returned, the data importer shall
continue to ensure compliance with these Clauses. In case of local laws
applicable to the data importer that prohibit return or deletion of the
personal data, the data importer warrants that it will continue to
ensure compliance with these Clauses and will only process it to the
extent and for as long as required under that local law. This is without
prejudice to Clause 14, in particular the requirement for the data
importer under Clause 14(e) to notify the data exporter throughout the
duration of the contract if it has reason to believe that it is or has
become subject to laws or practices not in line with the requirements
under Clause 14(a).
**8.6 Security of processing**
> \(a\) The data importer and, during transmission, also the data
> exporter shall implement appropriate technical and organisational
> measures to ensure the security of the data, including protection
> against a breach of security leading to accidental or unlawful
> destruction, loss, alteration, unauthorised disclosure or access to
> that data (hereinafter 'personal data breach'). In assessing the
> appropriate level of security, the Parties shall take due account of
> the state of the art, the costs of implementation, the nature, scope,
> context and purpose(s) of processing and the risks involved in the
> processing for the data subjects. The Parties shall in particular
> consider having recourse to encryption or pseudonymisation, including
> during transmission, where the purpose of processing can be fulfilled
> in that manner. In case of pseudonymisation, the additional
> information for attributing the personal data to a specific data
> subject shall, where possible, remain under the exclusive control of
> the data exporter. In complying with its obligations under this
> paragraph, the data importer shall at least implement the technical
> and organisational measures specified in Annex II. The data importer
> shall carry out regular checks to ensure that these measures continue
> to provide an appropriate level of security.
>
> \(b\) The data importer shall grant access to the personal data to
> members of its personnel only to the extent strictly necessary for the
> implementation, management and monitoring of the contract. It shall
> ensure that persons authorised to process the personal data have
> committed themselves to confidentiality or are under an appropriate
> statutory obligation of confidentiality.
>
> \(c\) In the event of a personal data breach concerning personal data
> processed by the data importer under these Clauses, the data importer
> shall take appropriate measures to address the breach, including
> measures to mitigate its adverse effects. The data importer shall also
> notify the data exporter without undue delay after having become aware
> of the breach. Such notification shall contain the details of a
> contact point where more information can be obtained, a description of
> the nature of the breach (including, where possible, categories and
> approximate number of data subjects and personal data records
> concerned), its likely consequences and the measures taken or proposed
> to address the breach including, where appropriate, measures to
> mitigate its possible adverse effects. Where, and in so far as, it is
> not possible to provide all information at the same time, the initial
> notification shall contain the information then available and further
> information shall, as it becomes available, subsequently be provided
> without undue delay.
>
> \(d\) The data importer shall cooperate with and assist the data
> exporter to enable the data exporter to comply with its obligations
> under Regulation (EU) 2016/679, in particular to notify the competent
> supervisory authority and the affected data subjects, taking into
> account the nature of processing and the information available to the
> data importer.
**8.7 Sensitive data**
Where the transfer involves personal data revealing racial or ethnic
origin, political opinions, religious or philosophical beliefs, or trade
union membership, genetic data, or biometric data for the purpose of
uniquely identifying a natural person, data concerning health or a
person's sex life or sexual orientation, or data relating to criminal
convictions and offences (hereinafter 'sensitive data'), the data
importer shall apply the specific restrictions and/or additional
safeguards described in Annex I.B.
**8.8 Onward transfers**
The data importer shall only disclose the personal data to a third party
on documented instructions from the data exporter. In addition, the data
may only be disclosed to a third party located outside the European
Union ([^2]) (in the same country as the data importer or in another
third country, hereinafter 'onward transfer') if the third party is or
agrees to be bound by these Clauses, under the appropriate Module, or
if:
> \(i\) the onward transfer is to a country benefitting from an adequacy
> decision pursuant to Article 45 of Regulation (EU) 2016/679 that
> covers the onward transfer;
>
> \(ii\) the third party otherwise ensures appropriate safeguards
> pursuant to Articles 46 or 47 Regulation of (EU) 2016/679 with respect
> to the processing in question;
>
> \(iii\) the onward transfer is necessary for the establishment,
> exercise or defence of legal claims in the context of specific
> administrative, regulatory or judicial proceedings; or
>
> \(iv\) the onward transfer is necessary in order to protect the vital
> interests of the data subject or of another natural person.
Any onward transfer is subject to compliance by the data importer with
all the other safeguards under these Clauses, in particular purpose
limitation.
**8.9 Documentation and compliance**
> \(a\) The data importer shall promptly and adequately deal with
> enquiries from the data exporter that relate to the processing under
> these Clauses.
>
> \(b\) The Parties shall be able to demonstrate compliance with these
> Clauses. In particular, the data importer shall keep appropriate
> documentation on the processing activities carried out on behalf of
> the data exporter.
>
> \(c\) The data importer shall make available to the data exporter all
> information necessary to demonstrate compliance with the obligations
> set out in these Clauses and at the data exporter's request, allow for
> and contribute to audits of the processing activities covered by these
> Clauses, at reasonable intervals or if there are indications of
> non-compliance. In deciding on a review or audit, the data exporter
> may take into account relevant certifications held by the data
> importer.
>
> \(d\) The data exporter may choose to conduct the audit by itself or
> mandate an independent auditor. Audits may include inspections at the
> premises or physical facilities of the data importer and shall, where
> appropriate, be carried out with reasonable notice.
>
> \(e\) The Parties shall make the information referred to in paragraphs
> (b) and (c), including the results of any audits, available to the
> competent supervisory authority on request.
***Clause 9***
**Use of sub-processors**
> \(a\) OPTION 1: SPECIFIC PRIOR AUTHORISATION The data importer shall
> not sub-contract any of its processing activities performed on behalf
> of the data exporter under these Clauses to a sub-processor without
> the data exporter's prior specific written authorisation. The data
> importer shall submit the request for specific authorisation at least
> \[*Specify time period*\] prior to the engagement of the
> sub-processor, together with the information necessary to enable the
> data exporter to decide on the authorisation. The list of
> sub-processors already authorised by the data exporter can be found in
> Annex III. The Parties shall keep Annex III up to date.
>
> OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the data
> exporter's general authorisation for the engagement of
> sub-processor(s) from an agreed list. The data importer shall
> specifically inform the data exporter in writing of any intended
> changes to that list through the addition or replacement of
> sub-processors at least \[*Specify time period*\] in advance, thereby
> giving the data exporter sufficient time to be able to object to such
> changes prior to the engagement of the sub-processor(s). The data
> importer shall provide the data exporter with the information
> necessary to enable the data exporter to exercise its right to object.
>
> \(b\) Where the data importer engages a sub-processor to carry out
> specific processing activities (on behalf of the data exporter), it
> shall do so by way of a written contract that provides for, in
> substance, the same data protection obligations as those binding the
> data importer under these Clauses, including in terms of third-party
> beneficiary rights for data subjects. ([^3]) The Parties agree that,
> by complying with this Clause, the data importer fulfils its
> obligations under Clause 8.8. The data importer shall ensure that the
> sub-processor complies with the obligations to which the data importer
> is subject pursuant to these Clauses.
>
> \(c\) The data importer shall provide, at the data exporter's request,
> a copy of such a sub-processor agreement and any subsequent amendments
> to the data exporter. To the extent necessary to protect business
> secrets or other confidential information, including personal data,
> the data importer may redact the text of the agreement prior to
> sharing a copy.
>
> \(d\) The data importer shall remain fully responsible to the data
> exporter for the performance of the sub-processor's obligations under
> its contract with the data importer. The data importer shall notify
> the data exporter of any failure by the sub-processor to fulfil its
> obligations under that contract.
>
> \(e\) The data importer shall agree a third-party beneficiary clause
> with the sub-processor whereby -- in the event the data importer has
> factually disappeared, ceased to exist in law or has become insolvent
> -- the data exporter shall have the right to terminate the
> sub-processor contract and to instruct the sub-processor to erase or
> return the personal data.
***Clause 10***
**Data subject rights**
> \(a\) The data importer shall promptly notify the data exporter of any
> request it has received from a data subject. It shall not respond to
> that request itself unless it has been authorised to do so by the data
> exporter.
>
> \(b\) The data importer shall assist the data exporter in fulfilling
> its obligations to respond to data subjects' requests for the exercise
> of their rights under Regulation (EU) 2016/679. In this regard, the
> Parties shall set out in Annex II the appropriate technical and
> organisational measures, taking into account the nature of the
> processing, by which the assistance shall be provided, as well as the
> scope and the extent of the assistance required.
>
> \(c\) In fulfilling its obligations under paragraphs (a) and (b), the
> data importer shall comply with the instructions from the data
> exporter.
***Clause 11***
**Redress**
> \(a\) The data importer shall inform data subjects in a transparent
> and easily accessible format, through individual notice or on its
> website, of a contact point authorised to handle complaints. It shall
> deal promptly with any complaints it receives from a data subject.
>
> \[OPTION: The data importer agrees that data subjects may also lodge a
> complaint with an independent dispute resolution body ([^4]) at no
> cost to the data subject. It shall inform the data subjects, in the
> manner set out in paragraph (a), of such redress mechanism and that
> they are not required to use it, or follow a particular sequence in
> seeking redress.\]
>
> \(b\) In case of a dispute between a data subject and one of the
> Parties as regards compliance with these Clauses, that Party shall use
> its best efforts to resolve the issue amicably in a timely fashion.
> The Parties shall keep each other informed about such disputes and,
> where appropriate, cooperate in resolving them.
>
> \(c\) Where the data subject invokes a third-party beneficiary right
> pursuant to Clause 3, the data importer shall accept the decision of
> the data subject to:
>
> \(i\) lodge a complaint with the supervisory authority in the Member
> State of his/her habitual residence or place of work, or the competent
> supervisory authority pursuant to Clause 13;
\(ii\) refer the dispute to the competent courts within the meaning of
Clause 18.
> \(d\) The Parties accept that the data subject may be represented by a
> not-for-profit body, organisation or association under the conditions
> set out in Article 80(1) of Regulation (EU) 2016/679.
>
> \(e\) The data importer shall abide by a decision that is binding
> under the applicable EU or Member State law.
>
> \(f\) The data importer agrees that the choice made by the data
> subject will not prejudice his/her substantive and procedural rights
> to seek remedies in accordance with applicable laws.
***Clause 12***
**Liability**
> \(a\) Each Party shall be liable to the other Party/ies for any
> damages it causes the other Party/ies by any breach of these Clauses.
>
> \(b\) The data importer shall be liable to the data subject, and the
> data subject shall be entitled to receive compensation, for any
> material or non-material damages the data importer or its
> sub-processor causes the data subject by breaching the third-party
> beneficiary rights under these Clauses.
>
> \(c\) Notwithstanding paragraph (b), the data exporter shall be liable
> to the data subject, and the data subject shall be entitled to receive
> compensation, for any material or non-material damages the data
> exporter or the data importer (or its sub-processor) causes the data
> subject by breaching the third-party beneficiary rights under these
> Clauses. This is without prejudice to the liability of the data
> exporter and, where the data exporter is a processor acting on behalf
> of a controller, to the liability of the controller under Regulation
> (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable.
>
> \(d\) The Parties agree that if the data exporter is held liable under
> paragraph (c) for damages caused by the data importer (or its
> sub-processor), it shall be entitled to claim back from the data
> importer that part of the compensation corresponding to the data
> importer's responsibility for the damage.
>
> \(e\) Where more than one Party is responsible for any damage caused
> to the data subject as a result of a breach of these Clauses, all
> responsible Parties shall be jointly and severally liable and the data
> subject is entitled to bring an action in court against any of these
> Parties.
>
> \(f\) The Parties agree that if one Party is held liable under
> paragraph (e), it shall be entitled to claim back from the other
> Party/ies that part of the compensation corresponding to its/their
> responsibility for the damage.
>
> \(g\) The data importer may not invoke the conduct of a sub-processor
> to avoid its own liability.
***Clause 13***
**Supervision**
(a) \[Where the data exporter is established in an EU Member State:\]
> The supervisory authority with responsibility for ensuring
> compliance by the data exporter with Regulation (EU) 2016/679 as
> regards the data transfer, as indicated in Annex I.C, shall act as
> competent supervisory authority.
> \[Where the data exporter is not established in an EU Member State,
> but falls within the territorial scope of application of Regulation
> (EU) 2016/679 in accordance with its Article 3(2) and has appointed a
> representative pursuant to Article 27(1) of Regulation (EU)
> 2016/679:\] The supervisory authority of the Member State in which the
> representative within the meaning of Article 27(1) of Regulation (EU)
> 2016/679 is established, as indicated in Annex I.C, shall act as
> competent supervisory authority.
>
> \[Where the data exporter is not established in an EU Member State,
> but falls within the territorial scope of application of Regulation
> (EU) 2016/679 in accordance with its Article 3(2) without however
> having to appoint a representative pursuant to Article 27(2) of
> Regulation (EU) 2016/679:\] The supervisory authority of one of the
> Member States in which the data subjects whose personal data is
> transferred under these Clauses in relation to the offering of goods
> or services to them, or whose behaviour is monitored, are located, as
> indicated in Annex I.C, shall act as competent supervisory authority.
>
> \(b\) The data importer agrees to submit itself to the jurisdiction of
> and cooperate with the competent supervisory authority in any
> procedures aimed at ensuring compliance with these Clauses. In
> particular, the data importer agrees to respond to enquiries, submit
> to audits and comply with the measures adopted by the supervisory
> authority, including remedial and compensatory measures. It shall
> provide the supervisory authority with written confirmation that the
> necessary actions have been taken.
**SECTION III -- LOCAL LAWS AND OBLIGATIONS IN CASE OF ACCESS BY PUBLIC
AUTHORITIES**
***Clause 14***
**Local laws and practices affecting compliance with the Clauses**
> \(a\) The Parties warrant that they have no reason to believe that the
> laws and practices in the third country of destination applicable to
> the processing of the personal data by the data importer, including
> any requirements to disclose personal data or measures authorising
> access by public authorities, prevent the data importer from
> fulfilling its obligations under these Clauses. This is based on the
> understanding that laws and practices that respect the essence of the
> fundamental rights and freedoms and do not exceed what is necessary
> and proportionate in a democratic society to safeguard one of the
> objectives listed in Article 23(1) of Regulation (EU) 2016/679, are
> not in contradiction with these Clauses.
>
> \(b\) The Parties declare that in providing the warranty in paragraph
> (a), they have taken due account in particular of the following
> elements:
>
> \(i\) the specific circumstances of the transfer, including the length
> of the processing chain, the number of actors involved and the
> transmission channels used; intended onward transfers; the type of
> recipient; the purpose of processing; the categories and format of the
> transferred personal data; the economic sector in which the transfer
> occurs; the storage location of the data transferred;
>
> \(ii\) the laws and practices of the third country of destination--
> including those requiring the disclosure of data to public authorities
> or authorising access by such authorities -- relevant in light of the
> specific circumstances of the transfer, and the applicable limitations
> and safeguards ([^5]);
>
> \(iii\) any relevant contractual, technical or organisational
> safeguards put in place to supplement the safeguards under these
> Clauses, including measures applied during transmission and to the
> processing of the personal data in the country of destination.
>
> \(c\) The data importer warrants that, in carrying out the assessment
> under paragraph (b), it has made its best efforts to provide the data
> exporter with relevant information and agrees that it will continue to
> cooperate with the data exporter in ensuring compliance with these
> Clauses.
>
> \(d\) The Parties agree to document the assessment under paragraph (b)
> and make it available to the competent supervisory authority on
> request.
>
> \(e\) The data importer agrees to notify the data exporter promptly
> if, after having agreed to these Clauses and for the duration of the
> contract, it has reason to believe that it is or has become subject to
> laws or practices not in line with the requirements under paragraph
> (a), including following a change in the laws of the third country or
> a measure (such as a disclosure request) indicating an application of
> such laws in practice that is not in line with the requirements in
> paragraph (a).
>
> \(f\) Following a notification pursuant to paragraph (e), or if the
> data exporter otherwise has reason to believe that the data importer
> can no longer fulfil its obligations under these Clauses, the data
> exporter shall promptly identify appropriate measures (e.g. technical
> or organisational measures to ensure security and confidentiality) to
> be adopted by the data exporter and/or data importer to address the
> situation. The data exporter shall suspend the data transfer if it
> considers that no appropriate safeguards for such transfer can be
> ensured, or if instructed by the competent supervisory authority to do
> so. In this case, the data exporter shall be entitled to terminate the
> contract, insofar as it concerns the processing of personal data under
> these Clauses. If the contract involves more than two Parties, the
> data exporter may exercise this right to termination only with respect
> to the relevant Party, unless the Parties have agreed otherwise. Where
> the contract is terminated pursuant to this Clause, Clause 16(d) and
> (e) shall apply.
***Clause 15***
**Obligations of the data importer in case of access by public
authorities**
**15.1 Notification**
> \(a\) The data importer agrees to notify the data exporter and, where
> possible, the data subject promptly (if necessary with the help of the
> data exporter) if it:
>
> \(i\) receives a legally binding request from a public authority,
> including judicial authorities, under the laws of the country of
> destination for the disclosure of personal data transferred pursuant
> to these Clauses; such notification shall include information about
> the personal data requested, the requesting authority, the legal basis
> for the request and the response provided; or
>
> \(ii\) becomes aware of any direct access by public authorities to
> personal data transferred pursuant to these Clauses in accordance with
> the laws of the country of destination; such notification shall
> include all information available to the importer.
>
> \(b\) If the data importer is prohibited from notifying the data
> exporter and/or the data subject under the laws of the country of
> destination, the data importer agrees to use its best efforts to
> obtain a waiver of the prohibition, with a view to communicating as
> much information as possible, as soon as possible. The data importer
> agrees to document its best efforts in order to be able to demonstrate
> them on request of the data exporter.
>
> \(c\) Where permissible under the laws of the country of destination,
> the data importer agrees to provide the data exporter, at regular
> intervals for the duration of the contract, with as much relevant
> information as possible on the requests received (in particular,
> number of requests, type of data requested, requesting authority/ies,
> whether requests have been challenged and the outcome of such
> challenges, etc.).
>
> \(d\) The data importer agrees to preserve the information pursuant to
> paragraphs (a) to (c) for the duration of the contract and make it
> available to the competent supervisory authority on request.
>
> \(e\) Paragraphs (a) to (c) are without prejudice to the obligation of
> the data importer pursuant to Clause 14(e) and Clause 16 to inform the
> data exporter promptly where it is unable to comply with these
> Clauses.
**15.2 Review of legality and data minimisation**
> \(a\) The data importer agrees to review the legality of the request
> for disclosure, in particular whether it remains within the powers
> granted to the requesting public authority, and to challenge the
> request if, after careful assessment, it concludes that there are
> reasonable grounds to consider that the request is unlawful under the
> laws of the country of destination, applicable obligations under
> international law and principles of international comity. The data
> importer shall, under the same conditions, pursue possibilities of
> appeal. When challenging a request, the data importer shall seek
> interim measures with a view to suspending the effects of the request
> until the competent judicial authority has decided on its merits. It
> shall not disclose the personal data requested until required to do so
> under the applicable procedural rules. These requirements are without
> prejudice to the obligations of the data importer under Clause 14(e).
>
> \(b\) The data importer agrees to document its legal assessment and
> any challenge to the request for disclosure and, to the extent
> permissible under the laws of the country of destination, make the
> documentation available to the data exporter. It shall also make it
> available to the competent supervisory authority on request.
>
> \(c\) The data importer agrees to provide the minimum amount of
> information permissible when responding to a request for disclosure,
> based on a reasonable interpretation of the request.
**SECTION IV -- FINAL PROVISIONS**
***Clause 16***
**Non-compliance with the Clauses and termination**
> \(a\) The data importer shall promptly inform the data exporter if it
> is unable to comply with these Clauses, for whatever reason.
>
> \(b\) In the event that the data importer is in breach of these
> Clauses or unable to comply with these Clauses, the data exporter
> shall suspend the transfer of personal data to the data importer until
> compliance is again ensured or the contract is terminated. This is
> without prejudice to Clause 14(f).
>
> \(c\) The data exporter shall be entitled to terminate the contract,
> insofar as it concerns the processing of personal data under these
> Clauses, where:
>
> \(i\) the data exporter has suspended the transfer of personal data to
> the data importer pursuant to paragraph (b) and compliance with these
> Clauses is not restored within a reasonable time and in any event
> within one month of suspension;
\(ii\) the data importer is in substantial or persistent breach of these
Clauses; or
> \(iii\) the data importer fails to comply with a binding decision of a
> competent court or supervisory authority regarding its obligations
> under these Clauses.
>
> In these cases, it shall inform the competent supervisory authority of
> such non-compliance. Where the contract involves more than two
> Parties, the data exporter may exercise this right to termination only
> with respect to the relevant Party, unless the Parties have agreed
> otherwise.
>
> \(d\) Personal data that has been transferred prior to the termination
> of the contract pursuant to paragraph (c) shall at the choice of the
> data exporter immediately be returned to the data exporter or deleted
> in its entirety. The same shall apply to any copies of the data. The
> data importer shall certify the deletion of the data to the data
> exporter. Until the data is deleted or returned, the data importer
> shall continue to ensure compliance with these Clauses. In case of
> local laws applicable to the data importer that prohibit the return or
> deletion of the transferred personal data, the data importer warrants
> that it will continue to ensure compliance with these Clauses and will
> only process the data to the extent and for as long as required under
> that local law.
>
> \(e\) Either Party may revoke its agreement to be bound by these
> Clauses where (i) the European Commission adopts a decision pursuant
> to Article 45(3) of Regulation (EU) 2016/679 that covers the transfer
> of personal data to which these Clauses apply; or (ii) Regulation (EU)
> 2016/679 becomes part of the legal framework of the country to which
> the personal data is transferred. This is without prejudice to other
> obligations applying to the processing in question under Regulation
> (EU) 2016/679.
***Clause 17***
**Governing law**
These Clauses shall be governed by the law of one of the EU Member
States, provided such law allows for third-party beneficiary rights. The
Parties agree that this shall be the law of \_\_\_\_\_\_\_ (specify
Member State).\]
***Clause 18***
**Choice of forum and jurisdiction**
> \(a\) Any dispute arising from these Clauses shall be resolved by the
> courts of an EU Member State.
\(b\) The Parties agree that those shall be the courts of \_\_\_\_\_
(*specify Member State*).
> \(c\) A data subject may also bring legal proceedings against the data
> exporter and/or data importer before the courts of the Member State in
> which he/she has his/her habitual residence.
\(d\) The Parties agree to submit themselves to the jurisdiction of such
courts.
## Annex 5: UK SCC (Controller to Processor)
**Note:** If there are actual differences between the official UK SCC Module and this version below, the official EU SCC Module prevails.
___
[CONTROLLER TO PROCESSOR MODEL CLAUSES: SET II]
Commission Decision C(2010)593
Standard Contractual Clauses (processors)
For the purposes of Article 26(2) UK GDPR for the transfer of personal data to processors established in third countries which do not ensure an adequate level of data protection
Name of the data exporting organisation: [Transferor]
(the data **exporter** )
And
Name of the data importing organisation: [Transferee]
(the data **importer** )
each a "party"; together "the parties",
HAVE AGREED on the following Contractual Clauses (the Clauses) in order to adduce adequate safeguards with respect to the protection of privacy and fundamental rights and freedoms of individuals for the transfer by the data exporter to the data importer of the personal data specified in Appendix 1.
_Clause 1_
_ **Definitions** _
For the purposes of the Clauses:
(a)'personal data', 'special categories of data', 'process/processing', 'controller', 'processor', 'data subject' and 'supervisory authority' shall have the same meaning as in the UK GDPR;
(b) 'the data exporter' means the controller who transfers the personal data;
(c) 'the data importer' means the processor who agrees to receive from the data exporter personal data intended for processing on his behalf after the transfer in accordance with his instructions and the terms of the Clauses and who is not subject to a third country's system ensuring adequate protection within the meaning of Article 25(1) UK GDPR;
(d) 'the subprocessor' means any processor engaged by the data importer or by any other subprocessor of the data importer who agrees to receive from the data importer or from any other subprocessor of the data importer personal data exclusively intended for processing activities to be carried out on behalf of the data exporter after the transfer in accordance with his instructions, the terms of the Clauses and the terms of the written subcontract;
(e) 'the applicable data protection law **'** means the legislation protecting the fundamental rights and freedoms of individuals and, in particular, their right to privacy with respect to the processing of personal data applicable to a data controller in the United Kingdom;
(f)'technical and organisational security measures' means those measures aimed at protecting personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing.
_Clause 2_
_ **Details of the transfer** _
The details of the transfer and in particular the special categories of personal data where applicable are specified in Appendix 1 which forms an integral part of the Clauses.
_Clause 3_
_ **Third-party beneficiary clause** _
1. The data subject can enforce against the data exporter this Clause, Clause 4(b) to (i), Clause 5(a) to (e), and (g) to (j), Clause 6(1) and (2), Clause 7, Clause 8(2), and Clauses 9 to 12 as third-party beneficiary.
2. The data subject can enforce against the data importer this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where the data exporter has factually disappeared or has ceased to exist in law unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law, as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity.
3. The data subject can enforce against the subprocessor this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
4. The parties do not object to a data subject being represented by an association or other body if the data subject so expressly wishes and if permitted by national law.
_Clause 4_
_ **Obligations of the data exporter** _
The data exporter agrees and warrants:
(a) that the processing, including the transfer itself, of the personal data has been and will continue to be carried out in accordance with the relevant provisions of the applicable data protection law (and, where applicable, has been notified to the relevant authorities in the United Kingdom) and does not violate the relevant provisions of the United Kingdom;
(b) that it has instructed and throughout the duration of the personal data processing services will instruct the data importer to process the personal data transferred only on the data exporter's behalf and in accordance with the applicable data protection law and the Clauses;
(c) that the data importer will provide sufficient guarantees in respect of the technical and organisational security measures specified in Appendix 2 to this contract;
(d) that after assessment of the requirements of the applicable data protection law, the security measures are appropriate to protect personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing, and that these measures ensure a level of security appropriate to the risks presented by the processing and the nature of the data to be protected having regard to the state of the art and the cost of their implementation;
(e) that it will ensure compliance with the security measures;
(f) that, if the transfer involves special categories of data, the data subject has been informed or will be informed before, or as soon as possible after, the transfer that its data could be transmitted to a third country not providing adequate protection within the meaning of the UK GDPR;
(g) to forward any notification received from the data importer or any subprocessor pursuant to Clause 5(b) and Clause 8(3) to the data protection supervisory authority if the data exporter decides to continue the transfer or to lift the suspension;
(h) to make available to the data subjects upon request a copy of the Clauses, with the exception of Appendix 2, and a summary description of the security measures, as well as a copy of any contract for subprocessing services which has to be made in accordance with the Clauses, unless the Clauses or the contract contain commercial information, in which case it may remove such commercial information;
(i) that, in the event of subprocessing, the processing activity is carried out in accordance with Clause 11 by a subprocessor providing at least the same level of protection for the personal data and the rights of data subject as the data importer under the Clauses; and
(j) that it will ensure compliance with Clause 4(a) to (i).
_Clause 5_
_ **Obligations of the data importer** _
_ **# 6** _
The data importer agrees and warrants:
(a) to process the personal data only on behalf of the data exporter and in compliance with its instructions and the Clauses; if it cannot provide such compliance for whatever reasons, it agrees to inform promptly the data exporter of its inability to comply, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(b) that it has no reason to believe that the legislation applicable to it prevents it from fulfilling the instructions received from the data exporter and its obligations under the contract and that in the event of a change in this legislation which is likely to have a substantial adverse effect on the warranties and obligations provided by the Clauses, it will promptly notify the change to the data exporter as soon as it is aware, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(c) that it has implemented the technical and organisational security measures specified in Appendix 2 before processing the personal data transferred;
(d) that it will promptly notify the data exporter about:
(i) any legally binding request for disclosure of the personal data by a law enforcement authority unless otherwise prohibited, such as a prohibition under criminal law to preserve the confidentiality of a law enforcement investigation,
(ii) any accidental or unauthorised access, and
(iii) any request received directly from the data subjects without responding to that request, unless it has been otherwise authorised to do so;
(e) to deal promptly and properly with all inquiries from the data exporter relating to its processing of the personal data subject to the transfer and to abide by the advice of the supervisory authority with regard to the processing of the data transferred;
(f) at the request of the data exporter to submit its data processing facilities for audit of the processing activities covered by the Clauses which shall be carried out by the data exporter or an inspection body composed of independent members and in possession of the required professional qualifications bound by a duty of confidentiality, selected by the data exporter, where applicable, in agreement with the supervisory authority;
(g) to make available to the data subject upon request a copy of the Clauses, or any existing contract for subprocessing, unless the Clauses or contract contain commercial information, in which case it may remove such commercial information, with the exception of Appendix 2 which shall be replaced by a summary description of the security measures in those cases where the data subject is unable to obtain a copy from the data exporter;
(h) that, in the event of subprocessing, it has previously informed the data exporter and obtained its prior written consent;
(i) that the processing services by the subprocessor will be carried out in accordance with Clause 11;
(j) to send promptly a copy of any subprocessor agreement it concludes under the Clauses to the data exporter.
_Clause 6_
_ **Liability** _
1. The parties agree that any data subject, who has suffered damage as a result of any breach of the obligations referred to in Clause 3 or in Clause 11 by any party or subprocessor is entitled to receive compensation from the data exporter for the damage suffered.
2. If a data subject is not able to bring a claim for compensation in accordance with paragraph 1 against the data exporter, arising out of a breach by the data importer or his subprocessor of any of their obligations referred to in Clause 3 or in Clause 11, because the data exporter has factually disappeared or ceased to exist in law or has become insolvent, the data importer agrees that the data subject may issue a claim against the data importer as if it were the data exporter, unless any successor entity has assumed the entire legal obligations of the data exporter by contract of by operation of law, in which case the data subject can enforce its rights against such entity.
The data importer may not rely on a breach by a subprocessor of its obligations in order to avoid its own liabilities.
3. If a data subject is not able to bring a claim against the data exporter or the data importer referred to in paragraphs 1 and 2, arising out of a breach by the subprocessor of any of their obligations referred to in Clause 3 or in Clause 11 because both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, the subprocessor agrees that the data subject may issue a claim against the data subprocessor with regard to its own processing operations under the Clauses as if it were the data exporter or the data importer, unless any successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law, in which case the data subject can enforce its rights against such entity. The liability of the subprocessor shall be limited to its own processing operations under the Clauses.
_Clause 7_
_ **Mediation and jurisdiction** _
1. The data importer agrees that if the data subject invokes against it third-party beneficiary rights and/or claims compensation for damages under the Clauses, the data importer will accept the decision of the data subject:
(a) to refer the dispute to mediation, by an independent person or, where applicable, by the supervisory authority;
(b) to refer the dispute to the courts in the United Kingdom.
2. The parties agree that the choice made by the data subject will not prejudice its substantive or procedural rights to seek remedies in accordance with other provisions of national or international law.
_Clause 8_
_ **Cooperation with supervisory authorities** _
1. The data exporter agrees to deposit a copy of this contract with the supervisory authority if it so requests or if such deposit is required under the applicable data protection law.
2. The parties agree that the supervisory authority has the right to conduct an audit of the data importer, and of any subprocessor, which has the same scope and is subject to the same conditions as would apply to an audit of the data exporter under the applicable data protection law.
3. The data importer shall promptly inform the data exporter about the existence of legislation applicable to it or any subprocessor preventing the conduct of an audit of the data importer, or any subprocessor, pursuant to paragraph 2. In such a case the data exporter shall be entitled to take the measures foreseen in Clause 5 (b).
_Clause 9_
_ **Governing Law** _
The Clauses shall be governed by the laws of England and Wales.
_Clause 10_
_ **Variation of the contract** _
The parties undertake not to vary or modify the Clauses. This does not preclude the parties from adding clauses on business related issues where required as long as they do not contradict the Clause.
_Clause 11_
_ **Subprocessing** _
1. The data importer shall not subcontract any of its processing operations performed on behalf of the data exporter under the Clauses without the prior written consent of the data exporter. Where the data importer subcontracts its obligations under the Clauses, with the consent of the data exporter, it shall do so only by way of a written agreement with the subprocessor which imposes the same obligations on the subprocessor as are imposed on the data importer under the Clauses[^7]. Where the subprocessor fails to fulfil its data protection obligations under such written agreement the data importer shall remain fully liable to the data exporter for the performance of the subprocessor's obligations under such agreement.
2. The prior written contract between the data importer and the subprocessor shall also provide for a third-party beneficiary clause as laid down in Clause 3 for cases where the data subject is not able to bring the claim for compensation referred to in paragraph 1 of Clause 6 against the data exporter or the data importer because they have factually disappeared or have ceased to exist in law or have become insolvent and no successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
3. The provisions relating to data protection aspects for subprocessing of the contract referred to in paragraph 1 shall be governed by the laws of England and Wales.
4. The data exporter shall keep a list of subprocessing agreements concluded under the Clauses and notified by the data importer pursuant to Clause 5 (j), which shall be updated at least once a year. The list shall be available to the data exporter's data protection supervisory authority.
_Clause 12_
_ **Obligation after the termination of personal data processing services** _
1. The parties agree that on the termination of the provision of data processing services, the data importer and the subprocessor shall, at the choice of the data exporter, return all the personal data transferred and the copies thereof to the data exporter or shall destroy all the personal data and certify to the data exporter that it has done so, unless legislation imposed upon the data importer prevents it from returning or destroying all or part of the personal data transferred. In that case, the data importer warrants that it will guarantee the confidentiality of the personal data transferred and will not actively process the personal data transferred anymore.
2. The data importer and the subprocessor warrant that upon request of the data exporter and/or of the supervisory authority, it will submit its data processing facilities for an audit of the measures referred to in paragraph 1.
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 1 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Data exporter**
The data exporter is (please specify briefly your activities relevant to the transfer):
xx
**Data importer**
The data importer is (please specify briefly activities relevant to the transfer):
xx
**Data subjects**
The personal data transferred concern the following categories of data subjects (please specify):
xx
**Categories of data**
The personal data transferred concern the following categories of data (please specify):
xx
**Special categories of data (if appropriate)**
The personal data transferred concern the following special categories of data (please specify):
xx
**Processing operations**
The personal data transferred will be subject to the following basic processing activities (please specify): [_insert_]
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 2 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Description of the technical and organisational security measures implemented by the data importer in accordance with Clauses 4(d) and 5(c):**
xx
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
___
[^1] Where the data exporter is a processor subject to Regulation (EU) 2016/679 acting on behalf of a Union institution or body as controller, reliance on these Clauses when engaging another processor (sub-processing) not subject to Regulation (EU) 2016/679 also ensures compliance with Article 29(4) of Regulation (EU) 2018/1725 of the European Parliament and of the Council of 23 October 2018 on the protection of natural persons with regard to the processing of personal data by the Union institutions, bodies, offices and agencies and on the free movement of such data, and repealing Regulation (EC) No 45/2001 and Decision No 1247/2002/EC ([OJ L 295, 21.11.2018, p. 39](https://eur-lex.europa.eu/legal-content/EN/AUTO/?uri=OJ:L:2018:295:TOC)), to the extent these Clauses and the data protection obligations as set out in the contract or other legal act between the controller and the processor pursuant to Article 29(3) of Regulation (EU) 2018/1725 are aligned. This will in particular be the case where the controller and processor rely on the standard contractual clauses included in Decision 2021/915.
[^2] The Agreement on the European Economic Area (EEA Agreement) provides for the extension of the European Union's internal market to the three EEA States Iceland, Liechtenstein and Norway. The Union data protection legislation, including Regulation (EU) 2016/679, is covered by the EEA Agreement and has been incorporated into Annex XI thereto. Therefore, any disclosure by the data importer to a third party located in the EEA does not qualify as an onward transfer for the purpose of these Clauses.
[^3] This requirement may be satisfied by the sub-processor acceding to these Clauses under the appropriate Module, in accordance with Clause 7.
[^4] The data importer may offer independent dispute resolution through an arbitration body only if it is established in a country that has ratified the New York Convention on Enforcement of Arbitration Awards.
[^5] As regards the impact of such laws and practices on compliance with these Clauses, different elements may be considered as part of an overall assessment. Such elements may include relevant and documented practical experience with prior instances of requests for disclosure from public authorities, or the absence of such requests, covering a sufficiently representative time-frame. This refers in particular to internal records or other documentation, drawn up on a continuous basis in accordance with due diligence and certified at senior management level, provided that this information can be lawfully shared with third parties. Where this practical experience is relied upon to conclude that the data importer will not be prevented from complying with these Clauses, it needs to be supported by other relevant, objective elements, and it is for the Parties to consider carefully whether these elements together carry sufficient weight, in terms of their reliability and representativeness, to support this conclusion. In particular, the Parties have to take into account whether their practical experience is corroborated and not contradicted by publicly available or otherwise accessible, reliable information on the existence or absence of requests within the same sector and/or the application of the law in practice, such as case law and reports by independent oversight bodies.
[^6] Mandatory requirements of the national legislation applicable to the data importer which do not go beyond what is necessary in a democratic society on the basis of one of the interests listed in Article 13(1) UK GDPR, that is, if they constitute a necessary measure to safeguard national security, defence, public security, the prevention, investigation, detection and prosecution of criminal offences or of breaches of ethics for the regulated professions, an important economic or financial interest of the State or the protection of the data subject or the rights and freedoms of others, are not in contradiction with the standard contractual clauses. Some examples of such mandatory requirements which do not go beyond what is necessary in a democratic society are, _inter__alia,_ internationally recognised sanctions, tax-reporting requirements or anti-money-laundering reporting requirements.
[^7] This requirement may be satisfied by the subprocessor co-signing the contract entered into between the data exporter and the data importer under this Decision.
---
## Data processing agreement (DPA) (effective 15 March 2022)
:::warning Superseded version
This is the **15 March 2022** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Data processing agreement (DPA)](/legal/dpa).
:::
:::tip Where to sign this document
Sign the Holistics Data Processing Agreement at: https://r.holistics.io/signdpa
:::
## Holistics Data Processing Agreement
_Last Updated: 15 March 2022_
**Definitions**
"California Personal Information" means Personal Data that is subject to the protection of the CCPA.
"CCPA" means California Civil Code Sec. 1798.100 et seq. (also known as the California Consumer Privacy Act of 2018).
"Consumer", "Business", "Sell" and "Service Provider" shall have the meanings given to them in the CCPA.
"Customer" refers to the Customer on a paid subscription plan with Holistics as described in the Terms, and all of its Affiliates.
"Customer Data" or "Customer Database" refers to all data residing in the Customer's database(s) and data source(s) connected to Holistics by Customer.
Customer End Users means the employees of the Customer who have been invited to access the Holistics Subscription Service in their customer account, or in contact with Holistics.
"Data Protection Laws" means all applicable worldwide legislation relating to data protection and privacy which applies to the respective party in the role of Processing Personal Data in question under the Agreement, including without limitation European Data Protection Laws (EU and UK GDPR), the US CCPA, the Swiss FDPA, the Singapore PDPA, and the data protection and privacy laws of Australia; in each case as amended, repealed, consolidated or replaced from time to time.
"Data Subject" means the individual to whom "Personal Data" relates.
"Database Metadata" refers to the following categories of metadata from the customers' database which includes broadly (but not limited to)
User credentials of data source(s). These credentials are applied with the necessary security encryption before storing them in Holistics database
The metadata (example the names of schemas, tables, fields, model relationships description) of the database table), excluding the physical data record entries
The metadata of definitions of objects made created within the Holistics application (dashboards, data sets, data models, automated schedules)
Any other metadata that may be added in from time to time.
"Europe" means the European Union, the European Economic Area and/or their member states, Switzerland and the United Kingdom.
"European Data" means Personal Data that is subject to the protection of European Data Protection Laws.
"European Data Protection Laws" means data protection laws applicable in Europe, including:
Regulation 2016/679 of the European Parliament and of the Council on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation) ("GDPR");
Directive 2002/58/EC concerning the processing of personal data and the protection of privacy in the electronic communications sector; and (iii) applicable national implementations of (i) and (ii); or
GDPR as it forms parts of the United Kingdom domestic law by virtue of Section 3 of the European Union (Withdrawal) Act 2018 ("UK GDPR")
Swiss Federal Data Protection Act on 19 June 1992 and its Ordinance (“Swiss DPA”); in each case, as may be amended, superseded or replaced.
"Instruction" means the written instruction, issued by Customer to Holistics, and directing the same to perform a specific action with regard to the Customer Database (including, but not limited to, depersonalising, blocking, deletion, making available). Instructions shall initially be specified in the Terms and may, from time to time thereafter, be amended, amplified or replaced by Customer in separate written instructions (individual instructions).
"PDPA" refers to the Personal Data Protection Act 2012 legislated in Singapore.
"Personal Data" means the personal data contained within the Customer Database, including any special categories of personal data defined under the Data Protection Laws of each jurisdiction, in each case that is processed by Holistics under the Terms.
"Personal Data Breach" means a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, Personal Data transmitted, stored or otherwise Processed by Holistics and/or its Sub-Processors in connection with the provision of the Subscription Services. "Personal Data Breach" shall not include unsuccessful attempts or activities that do not compromise the security of Personal Data, including unsuccessful log-in attempts, pings, port scans, denial of service attacks, and other network attacks on firewalls or networked systems.
"Process" or "Processing" means any operation or set of operations which is performed on Personal Data, encompassing the collection, recording, organization, structuring, storage, adaptation or alteration, retrieval, consultation, use, disclosure by transmission, dissemination or otherwise making available, alignment or combination, restriction or erasure of Personal Data.
"SCCs" means the Customer SCCs and/or SCCs as applicable.
Module 2: From a controller based in Europe to a processor (C2P)
Module 3: From a processor based in Europe to a processor (P2P)
UK SCC: From a controller based in UK to a Processor
"Sub-Processor" means any Processor engaged by Holistics, or Affiliates to assist in fulfilling the obligations with respect to the provision of the Subscription Services under the Agreement. Sub-Processors may include third parties or Affiliates but will exclude any Holistics employee or consultant.
"Temporary Cached Query Results" refer to all results provided to Customer, Customer End Users, or for System Consumption (APIs) for queries executed against Customer Database via Holistics for technical and performance reasons. These results are cached temporarily and will automatically expire after a specific time (minimum 10 minutes) after a unique SQL query is executed from the Customer Database.
"Terms" refers to the Terms of Service at https://www.holistics.io/terms/
### Introduction
This Data Processing Agreement ("DPA") reflects the parties' agreement with respect to the terms governing the Processing of data in the Customer Database under the Holistics Customer Terms of Service ("Terms"), and supersedes any previously signed DPA on an earlier date.
The DPA is an addon to, and forms an integral part of the Terms. It is effective upon its incorporation into the Terms, an online self-service purchase, or an Order or an executed amendment to the Agreement.
The terms "personal data", "data subject", "processing", "controller" and "processor" used in this DPA have the meanings given in the GDPR irrespective of whether European Data Protection Law or Non-European Data Protection Law applies.
The terms "Personal Data", "Customer Data", and "Customer Database" may be used interchangeably in this DPA.
This DPA shall follow the term of the Terms, including but not restricted to the Terms clauses
"Account Information from Third Party Providers"
"Limitation of Liability" and
"Indemnification" clauses.
In case of any conflict or inconsistency with the Terms, this DPA will take precedence to the extent of such conflict or inconsistency
The duration of Processing shall be the same as the duration of the Terms and this DPA.
The clauses of this DPA shall follow the Terms. Definitions not otherwise defined above herein shall have the meaning as set forth in the Terms.
### Holistics' Responsibilities
Holistics will only Process Customer Database for the purposes described in this DPA or as otherwise agreed within the scope of the Customer's Instructions, except where and to the extent otherwise required by applicable law.
Holistics will only access or use Customer Database to provide the Services ordered by Customer and will not use it for any other Holistics products, services, advertising, or to resell the data.
Holistics is not responsible for compliance with any Data Protection Laws applicable to the Customer's industry that are not applicable to us.
Holistics shall email the customer if we become aware of a confirmed breach and also further
Take any such reasonably necessary measures and actions to remedy or mitigate the effects of the Breach and
Keep the Customer informed of all material developments in connection with the Breach.
Provide reasonable information and cooperation so that the Customer can fulfill any data breach reporting obligations it may have under (and in accordance with the timescales required by) the applicable Data Protection law.
If any such request, correspondence, enquiry or complaint is made directly to the Holistics, Holistics will promptly inform the Customer providing full details of the same.
Holistics will take the appropriate technical and organisational measures (listed in Annex 2) to adequately protect Customer Database against misuse and loss in accordance with the requirements of the applicable national data protection law. Such measures hereunder shall include, but not be limited to,
the prevention of unauthorised persons from gaining access to Customer Database (physical access control),
the prevention of Customer Database from being accessed without authorisation (logical access control),
ensuring that Customer Database cannot be read, copied, modified or deleted without authorisation during electronic transmission and Holistics Software instance. (data transfer control),
Have a reasonable audit trail system in place to document whether and by whom information on Customer Database has been entered into, modified in, or removed from Customer Database (entry control),
ensuring that data from Customer Database are processed solely in accordance with the Instructions (control of instructions),
persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality,
Holistics Data Protection Team will provide prompt and reasonable assistance with any Customer queries related to processing of Customer Personal Data under the Agreement and can be contacted at [redacted].
### Customer Responsibilities
Customer is responsible for complying with all applicable Data Protection Laws with respect to its Processing of Personal Data in the Customer Database connected to Holistics.
Customer shall retain title to their Customer Database connected to the Holistics Software instance and take technical safeguards to provision (and not over-provision) the appropriate level of data source connection for the user credentials supplied to Holistics.
Customer shall be solely responsible for
the accuracy, quality, and legality of Customer Database and the means in which Personal Data is acquired;
complying with all necessary transparency and lawfulness requirements under applicable Data Protection Laws for the collection and use of the Personal Data, including obtaining any necessary consents and authorizations (particularly for use by Customer for marketing purposes);
complying with the statutory requirements relating to data protection, in particular regarding safeguards against unauthorized access to Customer Database from Holistics software systems.
Customer shall inform Holistics without undue delay and comprehensively about any errors or irregularities related to statutory provisions on the Processing of Customer Database detected during a verification of the results of such Processing.
Customer is responsible for security relating to its environment and databases and security relating its configuration of the Software. This includes implementing and managing procedural, technical, and administrative safeguards on its software and networks sufficient to:
ensure the confidentiality, security, integrity, and privacy of Customer Database in transit, at rest, and in storage;
protect against any anticipated threats or hazards to the security and integrity of Customer Database; and
protect against any unauthorized processing, loss, use, disclosure or acquisition of or access to Customer Database.
Customer will minimize the sharing of Personal Data of Data Subjects in the support tickets and emails information sent to Holistics.
If such Personal Data needs to be included for troubleshooting, the Customer will deliberately add specific Instructions to handle such email communications.
For the avoidance of doubt, emails sent by the Customer with generic company email content confidentiality boilerplates appended by default will not be classified as confidential information.
Notwithstanding any other provision of this DPA, the Terms or any other agreement related to the Software and Services, Holistics has no obligations or liability as to any breach or loss resulting from:
The Customer's environment, databases, systems or software, or
The Customer's security configuration or administration of the Software.
Customer is solely responsible for provisioning Users on the Software, including:
methods of authenticating Users (such as industry-standard secure username/password policies, two-factor authentication etc);
Restricting access by User or group, and from the database level down to the row or column level;
Managing admin privileges;
deauthorizing personnel who no longer need access to the Software;
setting up any API usage in a secure way; and
regularly auditing any public access links Users create and restricting the permission to create public links, as necessary.
Customer is responsible to remove the network connection between Customer Database and the Holistics Software Instance should they terminate the Subscription Service.
### Customer Database Sub-Processors
Customer consents to Holistics engaging affiliates and third party sub-processors to process data in Customer Database for the purpose as described in the Terms.
Holistics will maintain an up-to-date list of its sub-processors. For avoidance of doubt, the above consent constitutes Customer's prior written consent to the sub-Processing by Holistics (Annex 3)
Holistics will impose data protection terms on any sub-processor it appoints as required to protect Customer Data to the standard required by the Data Protection Laws.
If Holistics intends to instruct sub-Processors other than the companies listed in Annex 3, Holistics will notify the Customer thereof in writing (email to the email address(es) on record in Processor's account information for Customer is sufficient) and will give the Customer the opportunity to object to the engagement of the new sub-Processors within 30 days after being notified.
The objection, if raised, must be based on reasonable grounds (e.g. if the Customer proves that significant risks for the protection of its Customer Data exist at the sub-Processor).
In such an event, Holistics will either not appoint or replace the sub-processor or, if this is not possible, Customer may suspend or terminate the Terms (without prejudice to any fees incurred by Customer prior to suspension or termination).
**Data Transfers**
Customer acknowledges and agrees that Holistics may access and process Customer Data on a global basis as necessary to provide the Subscription Service in accordance with the Agreement, and in particular that Customer Data may be transferred to the data centre location(s) that Holistics operates in.
Holistics may store and process (i) Holistics Metadata and Usage Data and (ii) Temporary Cached Query Results anywhere Holistics or its Sub-processors maintain facilities, subject to Sections on \<Additional Provisions for European Data\>, \<Additional Provisions for California Personal Information\>, or other jurisdictions where Holistics operates in.
The physical data records residing in Customer Database will not be stored permanently by Holistics application servers outside of the purpose set in the Terms.
Temporary Cached Query Results needed to visualize the dashboard data will be temporarily stored in Holistics, and will automatically expire after a specific time duration.
Wherever Personal Data is transferred outside its country of origin, each party will ensure such transfers are made in compliance with the requirements of Data Protection Laws.
### Provisions Specific for European Data
The parties acknowledge and agree that European Data Protection Law will apply to the processing of Customer Data if
the processing is carried out in the context of the activities of an establishment of Customer in the territory of the EEA or the UK; and/or
Customer Personal Data is personal data relating to data subjects who are in the EEA or the UK and the processing relates to the offering to them of goods or services in the EEA or the UK, or the monitoring of their behavior in the EEA or the UK.
"Controller" means the natural or legal person, public authority, agency or other body which, alone or jointly with others, determines the purposes and means of the Processing of Personal Data
"Processor" means a natural or legal person, public authority, agency or other body which Processes Personal Data on behalf of the Controller.
Relationship between Customer and Holistics
Holistics is the Processor of the Customer Database for the purposes described in the Terms. "Processor"means a natural or legal person, public authority, agency or other body which Processes Personal Data on behalf of the Controller.
If Customer
is the Controller of data (which may include Personal Data and Data Subjects) stored in the Customer Database, the SCC Module 2 applies (Annex 4A - Controller to Processor)
is the Processor of data (which may include Personal Data and Data Subjects) stored in the Customer Database, the SCC Module 3 applies (Annex 4B - Processor to Processor)
Holistics and the Customer shall be separately responsible for conforming with such statutory data protection regulations as are applicable to them
Legacy MCCs: The SCCs will, as of the Transition Date, supersede and terminate any Model Contract Clauses approved under Directive 95/46/EC and previously entered into by Customer and Holistics. The Transition Date means October 27, 2021 if (a) Customer's billing address is outside EMEA, and (b) the processing of Customer Personal Data is subject to European Data Protection Law. If both (a) and (b) do not apply, the Transition Date is September 27, 2021.
Data Protection Impact Assessments and Consultation with Supervisory Authorities: Holistics will (taking into account the nature of the processing and the information available to Holistics) assist Customer in ensuring compliance with its (or, where Customer is a processor, the relevant controller's) obligations under Articles 35 and 36 of the GDPR, by:
Providing and updating our public documentation on technical security measures (https://docs.holistics.io/docs/data-security)
Providing public documentation on how Holistics caching and job queuing mechanism work (https://docs.holistics.io/docs/data-caching)
Providing the Security Measures (Annex 2) contained in the Agreement including these Terms; and
if the above subsections are insufficient for Customer (or the relevant controller) to comply with such obligations, upon Customer's request, providing Customer with additional reasonable cooperation and assistance.
Transfer Mechanism for Data Transfers:
Permitted Transfers. The parties acknowledge that European Data Protection Law does not require SCCs or an Alternative Transfer Solution in order for Customer Personal Data to be processed in or transferred to an Adequate Country ("Permitted Transfers").
Restricted Transfers. If the processing of Customer Personal Data is not processed in an Adequate Country, and European Data Protection Law applies to those transfers, then
The EU SCCs (EU Controller-to-Processor) will apply with respect to Restricted Transfers between Customer and Holistics that are subject to the EU GDPR and/or the Swiss FDPA; and
the UK SCCs (UK Controller-to-Processor) will apply (regardless of whether Customer is a controller and/or processor) with respect to Restricted Transfers between Customer and Holistics that are subject to the UK GDPR.
Holistics agrees to abide by and process European Data in compliance with the Standard Contractual Clauses.
Although Holistics does not rely on the Singapore Personal Data Protection Act 2012 ("PDPA") as a legal basis for transfers of Personal Data, Holistics will inform Customer if it is unable to comply with this requirement if any conflicts arise.
The parties agree that for the purposes of the Standard Contractual Clauses,
Holistics will be the "data importer" and Customer will be the "data exporter" (on behalf of itself and Permitted Affiliates);
the Annexes of the Standard Contractual Clauses shall be populated with the relevant information set out in Annex 1 and Annex 2 of this DPA;
if and to the extent the Standard Contractual Clauses conflict with any provision of this DPA, the Standard Contractual Clauses will prevail to the extent of such conflict.
To extent that and for so long as the Standard Contractual Clauses as implemented in accordance with this DPA cannot be relied on by the parties to lawfully transfer Personal Data in compliance with the GDPR, the applicable standard data protection clauses issued, adopted or permitted under the GDPR shall be incorporated by reference, and the annexes, appendices or tables of such clauses shall be deemed populated with the relevant information set out in Annex 1 and Annex 2 of this DPA.
Demonstration of Compliance
Holistics will make all information reasonably necessary to demonstrate compliance with this DPA available to Customer and allow for and contribute to audits, including inspections conducted by or an auditor appointed by Customer in order to assess compliance with this DPA.
Customer acknowledges and agree to exercise audit rights under this DPA and Clause 8 of the Standard Contractual Clauses by instructing Holistics to comply with the audit measures described in this 'Demonstration of Compliance' section.
Customer acknowledges that the Subscription Service is hosted by our data center partners (listed in our sub-processors) who maintain independently validated security programs.
Holistics may charge a fee (based on Holistics' reasonable costs) for any audit under Demonstration of Compliance. Holistics will provide the Customer with further details of any applicable fee, and the basis of its calculation, in advance of any such audit. Customer will be responsible for any fees charged by any auditor appointed by Customer to execute any such audit.
Holistics may object in writing to an auditor appointed by Customer to conduct any audit under Demonstration of Compliance if the auditor is, in Holistics' reasonable opinion, not suitably qualified or independent, a competitor of Holistics, or otherwise manifestly unsuitable. Any such objection by Holistics will require the Customer to appoint another auditor or conduct the audit itself.
Processing Records: Holistics will keep appropriate documentation of its processing activities. To the extent the GDPR requires Holistics to collect and maintain records of certain information relating to Customer, Customer will, where requested, supply such information to Holistics and keep it accurate and up-to-date. Holistics may make any such information available to the Supervisory Authorities if required by the GDPR.
No Modification of SCCs. Nothing in the Agreement (including these Terms) is intended to modify or contradict any SCCs or prejudice the fundamental rights or freedoms of data subjects under European Data Protection Law.
### Provisions Specific for California Personal Information
This section will apply only with respect to California Personal Information residing in Customer Database.
When processing California Personal Information in accordance with Customer's Instructions, the parties acknowledge and agree that Customer is a Business and Holistics is a Service Provider for the purposes of the CCPA.
Both parties agree that Holistics will Process California Personal Information as a Service Provider strictly for the purpose of performing the Subscription Services or as otherwise permitted by the CCPA, including as described in our Terms.
### Limitation of Liability
Each party's liability, taken together in the aggregate, arising out of or related to this DPA, and all DPAs between Customer and Holistics, whether in contract, tort or under any other theory of liability, is subject to the 'Limitation of Liability' section of the Terms, and any reference in such section to the liability of a party means the aggregate liability of that party under the Agreement and all DPAs together.
For the avoidance of doubt, Holistics' total liability for all claims from the Customer arising out of or related to the Agreement and each DPA shall apply in the aggregate for all claims under both the Agreement and all DPAs established under the Agreement by the Customer.
### Governing Law and Disputes
This DPA will be governed by and construed in accordance with the laws of the Singapore, unless otherwise required by
EU Data Protection Law, in which case this DPA will be governed by the laws of the Member State in which the Customer is established.
CCPA, in which case this DPA will be governed by the laws of California, USA.
the Data Protection Laws of each jurisdiction the Customer operates in
If Holistics becomes aware that Customer Data cannot be processed in accordance with the Customer's Instructions due to a legal requirement under any applicable law, Holistics will
promptly notify Customer that legal requirement to the extent permitted by the applicable law; and
where necessary, cease all Processing (other than merely storing and maintaining the security of the affected Customer Data) until such time as the Customer issues new Instructions with which Holistics is able to comply. If this provision is invoked, Holistics will not be liable to the Customer under the Agreement for any failure to perform the applicable Subscription Services until such time as Customer issues new lawful Instructions with regard to the Processing.
Arb-Med-Arb: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore.
The Tribunal shall consist of one (1) arbitrator(s)
The language of the arbitration shall be English
**Included Core Documents** :
- This Data Protection Agreement (DPA), as defined in https://r.holistics.io/dpa
- Holistics Terms of Service (Terms), as defined in [https://holistics.io/terms](https://holistics.io/terms)
- Annex 1: Subject Matter and Details of Data Processing
- Annex 2: Security Measures (Technical And Organisational Measures To Ensure The Security Of The Data)
- Annex 3: List of Holistics Sub-Processors
Selective Annex(es) where applicable to Customer
- Annex 4A: EU SCC Module 2 (Controller to Processor)
- Annex 4B: EU SCC Module 3 (Processor to Processor)
- Annex 5: UK SCC (Controller to Processor)
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 1: Subject Matter and Details of Data Processing
**A. LIST OF PARTIES**
**Data Exporter**
The data exporter is the Customer, a non-Holistics entity, as defined in the Holistics Terms of Service (Terms) at [https://www.holistics.io/terms/](https://www.holistics.io/terms/).
**Company Name** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Company Address** :
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Person Name** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Person Position** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Contact Position Email** : \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
**Customer Role (Check where it applies):**
- ☐ EU Controller - EU SCC Module 2 Applies (Controller to Processor) - Annex 4A
- □ EU Controller - EU SCC Module 2 Applies (Processor to Processor) - Annex 4B
- □ UK Controller - UK SCC Applies (Processor to Processor) - Annex 5
**Activities relevant to the data transferred under these Clauses:**
Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms")
**Data importer**
**Name** : Holistics Software Pte Ltd
**Address** : 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545
**Role** : Processor
**Contact person's name, position and contact details** :
Thanh Dinh Khac, Chief Engineer,
Holistics Software Pte Ltd
Email: [redactted]
**Activities relevant to the data transferred under these Clauses** : Processing of Personal Data in connection with Customer's use of the Holistics Subscription Services under the Holistics Terms of Service ("Terms")
**B. DESCRIPTION OF TRANSFER**
**Data subjects**
The personal data transferred concern the following categories of data subjects in two main categories
1. **Customer End Users of the Holistics Subscription Service** , mainly the employees of the Data Exporter, and other individuals who have been invited to access the Holistics Subscription Service in their customer account. This also includes users who have submitted their contact details through the Holistics website.
2. **Data subjects whose data is stored in the Exporter's database** connected to the Holistics application servers that may contain Personal Data.
**Categories of data**
1. **Holistics Metadata and Usage Data (From Customer End Users)**
The personal data transferred concern personal data, software license checks, audit trails, website usage information (URLs accessed, time of access, browser type), email data, metadata on reports and dashboards, data source schemas and other electronic data submitted, stored, sent, or received by users of the Subscription Service
2. **Temporary Cached Query Results from the Customer (Exporter)'s database**
Once the Customer's database is connected to the Holistics server, the Holistics cache temporarily retains data from the database that is fetched in response to a users' report queries. The Exporter can reduce the amount of time that query results are held in cache (minimum of 10 minutes), or to turn off the cache completely.
When a dashboard widget is exported into Excel/CSV file, the file will also be temporarily stored in Holistics' file storage system
**Sensitive Data Transferred and Applied Restrictions or Safeguards**
The parties do not anticipate the transfer of sensitive data. In the event sensitive data is stored in Customer's Database, Customer has flexibility to restrict or isolate sensitive data from the database user credential account that is used to connect to Holistics Software.
**Frequency of the transfer**
Adhoc. when a dashboard loads either from user access or from a scheduled job configured by the customer.
**Purpose of the transfer and further processing**
Holistics will process data for the purposes of providing the Subscription Services to Customer in accordance with the Holistics Terms of Service ("Terms").
**Period for which Data will be retained**
Temporary Cached Query Results will be stored for a minimum of 10 minutes (or higher) from the time the dashboard is first accessed.
Exported files (Excel/CSV downloads) are stored for up to 24 hours before they expire automatically.
Holistics Metadata and Usage Data (From Customer End Users) will be removed after 180 days after the Term expires, or earlier upon request by Customer..
**Competent Supervisory Authority**
For the purposes of the Standard Contractual Clauses, the supervisory authority that shall act as competent supervisory authority is either
1. **Where Customer is established in an EU Member State** , the supervisory authority responsible for ensuring Customer's compliance with the GDPR;
2. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR and has appointed a representative, the supervisory authority of the **EU Member State in which Customer's representative is established** ; or
3. Where Customer is not established in an EU Member State but falls within the extra-territorial scope of the GDPR without having to appoint a representative, the supervisory authority of the EU Member State in **which the Data Subjects are predominantly located** in relation to Data Processed that is subject to the UK GDPR or Swiss DPA, the competent supervisory authority is the UK Information Commissioner or the Swiss Federal Data Protection and Information Commissioner (as applicable).
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 2 - Security Measures
TECHNICAL AND ORGANISATIONAL MEASURES TO ENSURE THE SECURITY OF THE DATA
This Annex forms part of the DPA.Holistics currently observes the security practices described in this Annex 2.
Notwithstanding any provision to the contrary otherwise agreed to by Customer, Holistics may modify or update these practices at its discretion provided that such modification and update does not result in a material degradation in the protection offered by these practices.
All capitalized terms not otherwise defined herein shall have the meanings as set forth in the Holistics Terms of Service (Terms) stated at https://www.holistics.io/terms/.
**a) Access Control**
i) Preventing Unauthorized Product Access
**Outsourced processing** : Holistics hosts its Service with Digital Ocean, a data center provider based in Germany, United States, and Singapore. Additionally, Holistics maintains contractual relationships with vendors in order to provide the Service. Holistics relies on contractual agreements, privacy policies, and vendor compliance programs in order to assure the protection of data processed or stored by these vendors.
**Physical and environmental security** : Our servers for the Subscription Service are hosted with Digital Ocean and Amazon Web Services. Our data centres are based in the United States, Germany, and Singapore.
**Authentication** : Customers who interact with Holistics software must authenticate before accessing non-public customer data.
**Authorization** : Customer data is stored in multi-tenant storage systems accessible to Customers via only application user interfaces and application programming interfaces. Customers are not allowed direct access to the underlying application infrastructure.
The authorization model in each of Holistics' products is designed to ensure that only the appropriately assigned individuals can access relevant features, views, and customization options. Authorization to data sets is performed through validating the user's permissions against the attributes associated with each data set.
**Application Programming Interface (API) access**: Holistics allows the customer to expose public product APIs using an API key.
ii) Preventing Unauthorized Product Use
Holistics implements industry standard access controls and detection capabilities for the internal networks that support its products.
**Access controls** : Network access control mechanisms are designed to prevent network traffic using unauthorized protocols from reaching the product infrastructure.
**Static code analysis:** Security reviews of code stored in Holistics' source code repositories is performed, checking for coding best practices and identifiable software flaws.
**Responsible Disclosure** : A Responsible Disclosure program invites and incentivizes independent security researchers to ethically discover and disclose security flaws. This widens the available opportunities to engage with the security community and improve the product defenses against sophisticated attacks.
iii) Limitations of Privilege & Authorization Requirements
**Internal Data Access by personnel** :
Only authorized personnel are allowed access to the infrastructure provided are restricted to authorized personnel on the principle of least privilege.
SSH users use unique accounts to access production machines. Furthermore, the use of the root account is not used.
Access to sensitive systems and applications requires two factor authentication in the form of user ID, password, OTP and/or certificate
Holistics has established formal guidelines for passwords to govern the management and use of authentication mechanisms.All access is logged, and removed when appropriate.
Access to the corporate network, production machines, network devices, and support tools requires a unique ID.
**b) Transmission Control**
**In-transit** :
Holistics ensures that all connections to its web application from its users are encrypted.
Holistics uses configurations that ensure only approved networking ports and protocols are implemented, including firewalls.
Management has implemented tools to log network traffic into a system that allows monitoring and ad hoc queries.
**At-rest** :
Holistics encrypts Customer's database connection credentials and cached data stored at rest.
Access to sensitive systems and applications requires two factor authentication in the form of user ID, password, OTP and/or certificate
Only authorized users with the correct SSH key may gain access to production machines
**c) Input Control**
**Detection** : Holistics designed its infrastructure to log extensive information about the system behavior, traffic received, system authentication, and other application requests. Internal systems aggregated log data and alert appropriate employees of malicious, unintended, or anomalous activities. Holistics personnel, including security, operations, and support personnel, are responsive to known incidents.
**Response and tracking** : Holistics maintains a record of known security incidents that includes description, dates and times of relevant activities, and incident disposition. Suspected and confirmed security incidents are investigated by security, operations, or support personnel; and appropriate resolution steps are identified and documented. For any confirmed incidents, Holistics will take appropriate steps to minimize product and Customer damage or unauthorized disclosure.
**Communication** : If Holistics becomes aware of unlawful access to Customer data stored within its products, Holistics will:
1. Notify the affected Customers of the incident;
2. Provide a description of the steps Holistics is taking to resolve the incident; and
3. Provide status updates to the Customer contact, as Holistics deems necessary.
Notification(s) of incidents, if any, will be delivered to one or more of the Customer's contacts in a form Holistics selects, which may include via email or telephone.
**d) Data Storage**
Unlike most business intelligence software, Holistics Software does not store any physical records of Customer Data permanently. Instead Holistics generates SQL that directly queries the database and visualizes the records in the browser.
**Terminating Customers** : Holistics Metadata in active (i.e primary) databases is purged 180 days after a customer terminates all agreements for such products with Holistics, or upon a customer's written request. Information stored in backups, replicas, and snapshots is not automatically purged, but instead ages out of the system as part of the data lifecycle. Holistics reserves the right to alter data purging periods in order to address technical, compliance, or statutory requirements.
**e) Availability Control**
**Infrastructure availability** : The data center providers use commercially reasonable efforts to ensure a minimum of 99.9% uptime. The providers maintain a minimum of N+1 redundancy to power, network, and HVAC services.
**Fault tolerance** : Backup and replication strategies are designed to ensure redundancy and fail-over protections during a significant processing failure.
Holistics' products are designed to ensure redundancy and seamless failover. The server instances that support the products are also architected with a goal to prevent single points of failure. This design assists Holistics operations in maintaining and updating the product applications and backend while limiting downtime.
**f) Event Logging**
Holistics has implemented tools to
- collect and store server logs in a central location. The system can be queried in an ad hoc fashion by authorized users
- log application state into a system that allows monitoring and ad hoc queries.
- monitor Holistics Software SQL databases and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- monitor Holistics Software load balancers and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy
- monitor Holistics Software messaging queues and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- log network traffic into a system that allows monitoring and ad hoc queries.
- Monitor Holistics Software servers and notify appropriate personnel of any events or incidents based on predetermined criteria. Incidents are escalated per policy.
- Retain log entries **for at least 12 months**
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 3: List of Holistics Sub-Processors
"Sub-Processors for Customer Database" refers to the service providers that are used by Holistics as described in the DPA ([http://r.holistics.io/dpa](http://r.holistics.io/dpa)).
**Sub-processors For Customer Database**
**1. Amazon Web Services, Inc.**
**Entity Country** : USA
**Purpose** : Amazon S3, Redshift, RDS and/or other AWS services
**GDPR** : [https://aws.amazon.com/compliance/gdpr-center/](https://aws.amazon.com/compliance/gdpr-center/)
**2. DigitalOcean, Inc**
**Entity Country** : USA
**Server Country** : Singapore
**Purpose** : Infrastructure Hosting
**GDPR** : [https://www.digitalocean.com/security/gdpr/](https://www.digitalocean.com/security/gdpr/)
**3. Mailgun, Inc**
**Entity Country** : USA
**Purpose** : Transactional Emails
**GDPR** : [https://www.mailgun.com/gdpr](https://www.mailgun.com/gdpr)
**Sub-processors For Holistics Usage and Metadata**
The below sub-processors will process users and usage data from (or added by) Holistics users who are direct customers of Holistics (Customer End Users).
**1. Google, Inc.**
**Entity Country** : USA
**Purpose** : Google Cloud Platform, Google BigQuery, Google Sheets
**GDPR** : [https://cloud.google.com/security/gdpr/](https://cloud.google.com/security/gdpr/)
**Email** : [redacted]
**2. AppCues**
**Entity Country** : USA
**Purpose** : Onboarding Flow (For New Trial Users or New Releases)
**GDPR** : [https://docs.appcues.com/article/318-gdpr-information](https://docs.appcues.com/article/318-gdpr-information)
**Email** : [redacted]
**3. FullStory**
**Entity Country** : USA
**Purpose** : Facilitate Onboarding Study
**Remarks** : Assist for new free trial users (only) onboarding. Turned off for European residents and active paying customers.
**GDPR** : [https://help.fullstory.com/general-data-protection-regulation/gdpr](https://help.fullstory.com/general-data-protection-regulation/gdpr)
**Email** : [redacted]
**4. HubSpot**
**Entity Country** : USA
**Purpose** : Marketing Platform
**GDPR** : [https://www.hubspot.com/data-privacy/gdpr](https://www.hubspot.com/data-privacy/gdpr)
**Email** : [redacted]
**5. JotForm**
**Entity Country** : USA
**Purpose** : Survey Tool
**GDPR** : [https://www.jotform.com/gdpr-compliance/](https://www.jotform.com/gdpr-compliance/)
**Email** : [redacted]
**6. Pipedrive**
**Entity Country** : USA
**Purpose** : Sales Management
**GDPR** : [https://support.pipedrive.com/hc/en-us/articles/360000335129-Pipedrive-and-GDPR](https://support.pipedrive.com/hc/en-us/articles/360000335129-Pipedrive-and-GDPR)
**Email** : [redacted]
**7. Slack**
**Entity Country** : USA
**Purpose** : Holistics Integrations
**GDPR** : [https://slack.com/gdpr](https://slack.com/gdpr)
**8. Zapier**
**Entity Country** : USA
**Purpose** : Task Automation
**GDPR** : [https://zapier.com/help/gdpr/](https://zapier.com/help/gdpr/)
**Email** : [redacted]
**9. Zendesk**
**Applications Used** : Chat, Support
**Entity Country** : USA
**Purpose** : Product Support and Helpdesk
**GDPR:** [https://help.zendesk.com/hc/en-us/articles/360000586767-Complying-with-GDPR-in-Zendesk-products](https://help.zendesk.com/hc/en-us/articles/360000586767-Complying-with-GDPR-in-Zendesk-products)
**10. Notion**
**Entity Country** : USA
**Purpose** : Company KnowledgeBase of Customer End Users
**GDPR** : https://www.notion.so/GDPR-at-Notion-8952f065d96f4c18abf22fc3c85e272e
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 4A: EU SCC Module 2 (Controller to Processor)
**Note:** If there are actual differences between the official EU SCC
Module and this version below, the official EU SCC Module prevails.
---
STANDARD CONTRACTUAL CLAUSES
Controller to Processor
**SECTION I**
**_Clause 1_**
**Purpose and scope**
\(a\) The purpose of these standard contractual clauses is to ensure
compliance with the requirements of Regulation (EU) 2016/679 of the
European Parliament and of the Council of 27 April 2016 on the
protection of natural persons with regard to the processing of
personal data and on the free movement of such data (General Data
Protection Regulation) ([^1]) for the transfer of data to a third
country.
\(b\) The Parties:
\(i\) the natural or legal person(s), public authority/ies, agency/ies
or other body/ies (hereinafter 'entity/ies') transferring the personal
data, as listed in Annex I.A (hereinafter each 'data exporter'), and
\(ii\) the entity/ies in a third country receiving the personal data
from the data exporter, directly or indirectly via another entity also
Party to these Clauses, as listed in Annex I.A (hereinafter each 'data
importer')
have agreed to these standard contractual clauses (hereinafter:
'Clauses').
\(c\) These Clauses apply with respect to the transfer of personal
data as specified in Annex I.B.
\(d\) The Appendix to these Clauses containing the Annexes referred to
therein forms an integral part of these Clauses.
**_Clause 2_**
**Effect and invariability of the Clauses**
\(a\) These Clauses set out appropriate safeguards, including
enforceable data subject rights and effective legal remedies, pursuant
to Article 46(1) and Article 46(2)(c) of Regulation (EU) 2016/679 and,
with respect to data transfers from controllers to processors and/or
processors to processors, standard contractual clauses pursuant to
Article 28(7) of Regulation (EU) 2016/679, provided they are not
modified, except to select the appropriate Module(s) or to add or
update information in the Appendix. This does not prevent the Parties
from including the standard contractual clauses laid down in these
Clauses in a wider contract and/or to add other clauses or additional
safeguards, provided that they do not contradict, directly or
indirectly, these Clauses or prejudice the fundamental rights or
freedoms of data subjects.
\(b\) These Clauses are without prejudice to obligations to which the
data exporter is subject by virtue of Regulation (EU) 2016/679.
**_Clause 3_**
**Third-party beneficiaries**
\(a\) Data subjects may invoke and enforce these Clauses, as
third-party beneficiaries, against the data exporter and/or data
importer, with the following exceptions:
\(i\) Clause 1, Clause 2, Clause 3, Clause 6, Clause 7;
\(ii\) Clause 8.1(b), 8.9(a), (c), (d) and (e);
\(iii\) Clause 9(a), (c), (d) and (e);
\(iv\) Clause 12(a), (d) and (f);
\(v\) Clause 13;
\(vi\) Clause 15.1(c), (d) and (e);
\(vii\) Clause 16(e);
\(viii\) Clause 18(a) and (b).
\(b\) Paragraph (a) is without prejudice to rights of data subjects
under Regulation (EU) 2016/679.
**_Clause 4_**
**Interpretation**
\(a\) Where these Clauses use terms that are defined in Regulation
(EU) 2016/679, those terms shall have the same meaning as in that
Regulation.
\(b\) These Clauses shall be read and interpreted in the light of the
provisions of Regulation (EU) 2016/679.
\(c\) These Clauses shall not be interpreted in a way that conflicts
with rights and obligations provided for in Regulation (EU) 2016/679.
**_Clause 5_**
**Hierarchy**
In the event of a contradiction between these Clauses and the provisions
of related agreements between the Parties, existing at the time these
Clauses are agreed or entered into thereafter, these Clauses shall
prevail.
**_Clause 6_**
**Description of the transfer(s)**
The details of the transfer(s), and in particular the categories of
personal data that are transferred and the purpose(s) for which they are
transferred, are specified in Annex I.B.
**_Clause 7 -- Optional_**
**Docking clause**
\(a\) An entity that is not a Party to these Clauses may, with the
agreement of the Parties, accede to these Clauses at any time, either
as a data exporter or as a data importer, by completing the Appendix
and signing Annex I.A.
\(b\) Once it has completed the Appendix and signed Annex I.A, the
acceding entity shall become a Party to these Clauses and have the
rights and obligations of a data exporter or data importer in> accordance with its designation in Annex I.A.
\(c\) The acceding entity shall have no rights or obligations arising
under these Clauses from the period prior to becoming a Party.
**SECTION II -- OBLIGATIONS OF THE PARTIES**
**_Clause 8_**
**Data protection safeguards**
The data exporter warrants that it has used reasonable efforts to
determine that the data importer is able, through the implementation of
appropriate technical and organisational measures, to satisfy its
obligations under these Clauses.
**8.1 Instructions**
\(a\) The data importer shall process the personal data only on
documented instructions from the data exporter. The data exporter may
give such instructions throughout the duration of the contract.
\(b\) The data importer shall immediately inform the data exporter if
it is unable to follow those instructions.
**8.2 Purpose limitation**
The data importer shall process the personal data only for the specific
purpose(s) of the transfer, as set out in Annex I.B, unless on further
instructions from the data exporter.
**8.3 Transparency**
On request, the data exporter shall make a copy of these Clauses,
including the Appendix as completed by the Parties, available to the
data subject free of charge. To the extent necessary to protect business
secrets or other confidential information, including the measures
described in Annex II and personal data, the data exporter may redact
part of the text of the Appendix to these Clauses prior to sharing a
copy, but shall provide a meaningful summary where the data subject
would otherwise not be able to understand the its content or exercise
his/her rights. On request, the Parties shall provide the data subject
with the reasons for the redactions, to the extent possible without
revealing the redacted information. This Clause is without prejudice to
the obligations of the data exporter under Articles 13 and 14 of
Regulation (EU) 2016/679.
**8.4 Accuracy**
If the data importer becomes aware that the personal data it has
received is inaccurate, or has become outdated, it shall inform the data
exporter without undue delay. In this case, the data importer shall
cooperate with the data exporter to erase or rectify the data.
**8.5 Duration of processing and erasure or return of data**
Processing by the data importer shall only take place for the duration
specified in Annex I.B. After the end of the provision of the processing
services, the data importer shall, at the choice of the data exporter,
delete all personal data processed on behalf of the data exporter and
certify to the data exporter that it has done so, or return to the data
exporter all personal data processed on its behalf and delete existing
copies. Until the data is deleted or returned, the data importer shall
continue to ensure compliance with these Clauses. In case of local laws
applicable to the data importer that prohibit return or deletion of the
personal data, the data importer warrants that it will continue to
ensure compliance with these Clauses and will only process it to the
extent and for as long as required under that local law. This is without
prejudice to Clause 14, in particular the requirement for the data
importer under Clause 14(e) to notify the data exporter throughout the
duration of the contract if it has reason to believe that it is or has
become subject to laws or practices not in line with the requirements
under Clause 14(a).
**8.6 Security of processing**
\(a\) The data importer and, during transmission, also the data
exporter shall implement appropriate technical and organisational
easures to ensure the security of the data, including protection
gainst a breach of security leading to accidental or unlawful
estruction, loss, alteration, unauthorised disclosure or access to
hat data (hereinafter 'personal data breach'). In assessing the
ppropriate level of security, the Parties shall take due account of
he state of the art, the costs of implementation, the nature, scope,
ontext and purpose(s) of processing and the risks involved in the
rocessing for the data subjects. The Parties shall in particular
onsider having recourse to encryption or pseudonymisation, including
uring transmission, where the purpose of processing can be fulfilled
n that manner. In case of pseudonymisation, the additional
nformation for attributing the personal data to a specific data
ubject shall, where possible, remain under the exclusive control of
he data exporter. In complying with its obligations under this
aragraph, the data importer shall at least implement the technical
nd organisational measures specified in Annex II. The data importer
hall carry out regular checks to ensure that these measures continue
o provide an appropriate level of security.
(b\) The data importer shall grant access to the personal data to
embers of its personnel only to the extent strictly necessary for the
mplementation, management and monitoring of the contract. It shall
nsure that persons authorised to process the personal data have
ommitted themselves to confidentiality or are under an appropriate
tatutory obligation of confidentiality.
(c\) In the event of a personal data breach concerning personal data
rocessed by the data importer under these Clauses, the data importer
hall take appropriate measures to address the breach, including
easures to mitigate its adverse effects. The data importer shall also
otify the data exporter without undue delay after having become aware
f the breach. Such notification shall contain the details of a
ontact point where more information can be obtained, a description of
he nature of the breach (including, where possible, categories and
pproximate number of data subjects and personal data records
oncerned), its likely consequences and the measures taken or proposed
o address the breach including, where appropriate, measures to
itigate its possible adverse effects. Where, and in so far as, it is
ot possible to provide all information at the same time, the initial
otification shall contain the information then available and further
nformation shall, as it becomes available, subsequently be provided
ithout undue delay.
(d\) The data importer shall cooperate with and assist the data
xporter to enable the data exporter to comply with its obligations
nder Regulation (EU) 2016/679, in particular to notify the competent
upervisory authority and the affected data subjects, taking into
ccount the nature of processing and the information available to the
data importer.
*8.7 Sensitive data**
Where the transfer involves personal data revealing racial or ethnic
origin, political opinions, religious or philosophical beliefs, or trade
union membership, genetic data, or biometric data for the purpose of
uniquely identifying a natural person, data concerning health or a
person's sex life or sexual orientation, or data relating to criminal
convictions and offences (hereinafter 'sensitive data'), the data
importer shall apply the specific restrictions and/or additional
safeguards described in Annex I.B.
**8.8 Onward transfers**
The data importer shall only disclose the personal data to a third party
on documented instructions from the data exporter. In addition, the data
may only be disclosed to a third party located outside the European
Union ([^2]) (in the same country as the data importer or in another
third country, hereinafter 'onward transfer') if the third party is or
agrees to be bound by these Clauses, under the appropriate Module, or
if:
\(i\) the onward transfer is to a country benefitting from an adequacy
decision pursuant to Article 45 of Regulation (EU) 2016/679 that
covers the onward transfer;
\(ii\) the third party otherwise ensures appropriate safeguards
pursuant to Articles 46 or 47 Regulation of (EU) 2016/679 with respect
to the processing in question;
\(iii\) the onward transfer is necessary for the establishment,
exercise or defence of legal claims in the context of specific
administrative, regulatory or judicial proceedings; or
\(iv\) the onward transfer is necessary in order to protect the vital
interests of the data subject or of another natural person.
Any onward transfer is subject to compliance by the data importer with
all the other safeguards under these Clauses, in particular purpose
limitation.
**8.9 Documentation and compliance**
\(a\) The data importer shall promptly and adequately deal with
enquiries from the data exporter that relate to the processing under
these Clauses.
\(b\) The Parties shall be able to demonstrate compliance with these
Clauses. In particular, the data importer shall keep appropriate
documentation on the processing activities carried out on behalf of
the data exporter.
\(c\) The data importer shall make available to the data exporter all
information necessary to demonstrate compliance with the obligations
set out in these Clauses and at the data exporter's request, allow for
and contribute to audits of the processing activities covered by these
Clauses, at reasonable intervals or if there are indications of
non-compliance. In deciding on a review or audit, the data exporter
may take into account relevant certifications held by the data
importer.
\(d\) The data exporter may choose to conduct the audit by itself or
mandate an independent auditor. Audits may include inspections at the
premises or physical facilities of the data importer and shall, where
appropriate, be carried out with reasonable notice.
\(e\) The Parties shall make the information referred to in paragraphs
(b) and (c), including the results of any audits, available to the
competent supervisory authority on request.
**_Clause 9_**
**Use of sub-processors**
\(a\) OPTION 1: SPECIFIC PRIOR AUTHORISATION The data importer shall
not sub-contract any of its processing activities performed on behalf
of the data exporter under these Clauses to a sub-processor without
the data exporter's prior specific written authorisation. The data
importer shall submit the request for specific authorisation at least
\[_Specify time period_\] prior to the engagement of the
sub-processor, together with the information necessary to enable the
data exporter to decide on the authorisation. The list of
sub-processors already authorised by the data exporter can be found in
Annex III. The Parties shall keep Annex III up to date.
OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the data
exporter's general authorisation for the engagement of
sub-processor(s) from an agreed list. The data importer shall
specifically inform the data exporter in writing of any intended
changes to that list through the addition or replacement of
sub-processors at least \[_Specify time period_\] in advance, thereby
giving the data exporter sufficient time to be able to object to such
changes prior to the engagement of the sub-processor(s). The data
importer shall provide the data exporter with the information
necessary to enable the data exporter to exercise its right to object.
\(b\) Where the data importer engages a sub-processor to carry out
specific processing activities (on behalf of the data exporter), it
shall do so by way of a written contract that provides for, in
substance, the same data protection obligations as those binding the
data importer under these Clauses, including in terms of third-party
beneficiary rights for data subjects. ([^3]) The Parties agree that,
by complying with this Clause, the data importer fulfils its
obligations under Clause 8.8. The data importer shall ensure that the
sub-processor complies with the obligations to which the data importer
is subject pursuant to these Clauses.
\(c\) The data importer shall provide, at the data exporter's request,
a copy of such a sub-processor agreement and any subsequent amendments
to the data exporter. To the extent necessary to protect business
secrets or other confidential information, including personal data,
the data importer may redact the text of the agreement prior to
sharing a copy.
\(d\) The data importer shall remain fully responsible to the data
exporter for the performance of the sub-processor's obligations under
its contract with the data importer. The data importer shall notify
the data exporter of any failure by the sub-processor to fulfil its
obligations under that contract.
\(e\) The data importer shall agree a third-party beneficiary clause
with the sub-processor whereby -- in the event the data importer has
factually disappeared, ceased to exist in law or has become insolvent
-- the data exporter shall have the right to terminate the
sub-processor contract and to instruct the sub-processor to erase or
return the personal data.
**_Clause 10_**
**Data subject rights**
\(a\) The data importer shall promptly notify the data exporter of any
request it has received from a data subject. It shall not respond to
that request itself unless it has been authorised to do so by the data
exporter.
\(b\) The data importer shall assist the data exporter in fulfilling
its obligations to respond to data subjects' requests for the exercise
of their rights under Regulation (EU) 2016/679. In this regard, the
Parties shall set out in Annex II the appropriate technical and
organisational measures, taking into account the nature of the
processing, by which the assistance shall be provided, as well as the
scope and the extent of the assistance required.
\(c\) In fulfilling its obligations under paragraphs (a) and (b), the
data importer shall comply with the instructions from the data
exporter.
**_Clause 11_**
**Redress**
\(a\) The data importer shall inform data subjects in a transparent
and easily accessible format, through individual notice or on its
website, of a contact point authorised to handle complaints. It shall
deal promptly with any complaints it receives from a data subject.
\[OPTION: The data importer agrees that data subjects may also lodge a
complaint with an independent dispute resolution body ([^4]) at no
cost to the data subject. It shall inform the data subjects, in the
manner set out in paragraph (a), of such redress mechanism and that
they are not required to use it, or follow a particular sequence in
seeking redress.\]
\(b\) In case of a dispute between a data subject and one of the
Parties as regards compliance with these Clauses, that Party shall use
its best efforts to resolve the issue amicably in a timely fashion.
The Parties shall keep each other informed about such disputes and,
where appropriate, cooperate in resolving them.
\(c\) Where the data subject invokes a third-party beneficiary right
pursuant to Clause 3, the data importer shall accept the decision of
the data subject to:
\(i\) lodge a complaint with the supervisory authority in the Member
State of his/her habitual residence or place of work, or the competent
supervisory authority pursuant to Clause 13;
\(ii\) refer the dispute to the competent courts within the meaning of
Clause 18.
\(d\) The Parties accept that the data subject may be represented by a
not-for-profit body, organisation or association under the conditions
set out in Article 80(1) of Regulation (EU) 2016/679.
\(e\) The data importer shall abide by a decision that is binding
under the applicable EU or Member State law.
\(f\) The data importer agrees that the choice made by the data
subject will not prejudice his/her substantive and procedural rights
to seek remedies in accordance with applicable laws.
**_Clause 12_**
**Liability**
\(a\) Each Party shall be liable to the other Party/ies for any
damages it causes the other Party/ies by any breach of these Clauses.
\(b\) The data importer shall be liable to the data subject, and the
data subject shall be entitled to receive compensation, for any
material or non-material damages the data importer or its
sub-processor causes the data subject by breaching the third-party
beneficiary rights under these Clauses.
\(c\) Notwithstanding paragraph (b), the data exporter shall be liable
to the data subject, and the data subject shall be entitled to receive
compensation, for any material or non-material damages the data
exporter or the data importer (or its sub-processor) causes the data
subject by breaching the third-party beneficiary rights under these
Clauses. This is without prejudice to the liability of the data
exporter and, where the data exporter is a processor acting on behalf
of a controller, to the liability of the controller under Regulation
(EU) 2016/679 or Regulation (EU) 2018/1725, as applicable.
\(d\) The Parties agree that if the data exporter is held liable under
paragraph (c) for damages caused by the data importer (or its
sub-processor), it shall be entitled to claim back from the data
importer that part of the compensation corresponding to the data
importer's responsibility for the damage.
\(e\) Where more than one Party is responsible for any damage caused
to the data subject as a result of a breach of these Clauses, all
responsible Parties shall be jointly and severally liable and the data
subject is entitled to bring an action in court against any of these
Parties.
\(f\) The Parties agree that if one Party is held liable under
paragraph (e), it shall be entitled to claim back from the other
Party/ies that part of the compensation corresponding to its/their
responsibility for the damage.
\(g\) The data importer may not invoke the conduct of a sub-processor
to avoid its own liability.
**_Clause 13_**
**Supervision**
(a) \[Where the data exporter is established in an EU Member State:\] > The supervisory authority with responsibility for ensuring > compliance by the data exporter with Regulation (EU) 2016/679 as > regards the data transfer, as indicated in Annex I.C, shall act as > competent supervisory authority.
\[Where the data exporter is not established in an EU Member State,
but falls within the territorial scope of application of Regulation
(EU) 2016/679 in accordance with its Article 3(2) and has appointed a
representative pursuant to Article 27(1) of Regulation (EU)
2016/679:\] The supervisory authority of the Member State in which the
representative within the meaning of Article 27(1) of Regulation (EU)
2016/679 is established, as indicated in Annex I.C, shall act as
competent supervisory authority.
\[Where the data exporter is not established in an EU Member State,
but falls within the territorial scope of application of Regulation
(EU) 2016/679 in accordance with its Article 3(2) without however
having to appoint a representative pursuant to Article 27(2) of
Regulation (EU) 2016/679:\] The supervisory authority of one of the
Member States in which the data subjects whose personal data is
transferred under these Clauses in relation to the offering of goods
or services to them, or whose behaviour is monitored, are located, as
indicated in Annex I.C, shall act as competent supervisory authority.
\(b\) The data importer agrees to submit itself to the jurisdiction of
and cooperate with the competent supervisory authority in any
procedures aimed at ensuring compliance with these Clauses. In
particular, the data importer agrees to respond to enquiries, submit
to audits and comply with the measures adopted by the supervisory
authority, including remedial and compensatory measures. It shall
provide the supervisory authority with written confirmation that the
necessary actions have been taken.
**SECTION III -- LOCAL LAWS AND OBLIGATIONS IN CASE OF ACCESS BY PUBLIC
AUTHORITIES**
**_Clause 14_**
**Local laws and practices affecting compliance with the Clauses**
\(a\) The Parties warrant that they have no reason to believe that the
laws and practices in the third country of destination applicable to
the processing of the personal data by the data importer, including
any requirements to disclose personal data or measures authorising
access by public authorities, prevent the data importer from
fulfilling its obligations under these Clauses. This is based on the
understanding that laws and practices that respect the essence of the
fundamental rights and freedoms and do not exceed what is necessary
and proportionate in a democratic society to safeguard one of the
objectives listed in Article 23(1) of Regulation (EU) 2016/679, are
not in contradiction with these Clauses.
\(b\) The Parties declare that in providing the warranty in paragraph
(a), they have taken due account in particular of the following
elements:
\(i\) the specific circumstances of the transfer, including the length
of the processing chain, the number of actors involved and the
transmission channels used; intended onward transfers; the type of
recipient; the purpose of processing; the categories and format of the
transferred personal data; the economic sector in which the transfer
occurs; the storage location of the data transferred;
\(ii\) the laws and practices of the third country of destination--
including those requiring the disclosure of data to public authorities
or authorising access by such authorities -- relevant in light of the
specific circumstances of the transfer, and the applicable limitations
and safeguards ([^5]);
\(iii\) any relevant contractual, technical or organisational
safeguards put in place to supplement the safeguards under these
Clauses, including measures applied during transmission and to the
processing of the personal data in the country of destination.
\(c\) The data importer warrants that, in carrying out the assessment
under paragraph (b), it has made its best efforts to provide the data
exporter with relevant information and agrees that it will continue to
cooperate with the data exporter in ensuring compliance with these
Clauses.
\(d\) The Parties agree to document the assessment under paragraph (b)
and make it available to the competent supervisory authority on
request.
\(e\) The data importer agrees to notify the data exporter promptly
if, after having agreed to these Clauses and for the duration of the
contract, it has reason to believe that it is or has become subject to
laws or practices not in line with the requirements under paragraph
(a), including following a change in the laws of the third country or
a measure (such as a disclosure request) indicating an application of
such laws in practice that is not in line with the requirements in
paragraph (a).
\(f\) Following a notification pursuant to paragraph (e), or if the
data exporter otherwise has reason to believe that the data importer
can no longer fulfil its obligations under these Clauses, the data
exporter shall promptly identify appropriate measures (e.g. technical
or organisational measures to ensure security and confidentiality) to
be adopted by the data exporter and/or data importer to address the
situation. The data exporter shall suspend the data transfer if it
considers that no appropriate safeguards for such transfer can be
ensured, or if instructed by the competent supervisory authority to do
so. In this case, the data exporter shall be entitled to terminate the
contract, insofar as it concerns the processing of personal data under
these Clauses. If the contract involves more than two Parties, the
data exporter may exercise this right to termination only with respect
to the relevant Party, unless the Parties have agreed otherwise. Where
the contract is terminated pursuant to this Clause, Clause 16(d) and
(e) shall apply.
**_Clause 15_**
**Obligations of the data importer in case of access by public
authorities**
**15.1 Notification**
\(a\) The data importer agrees to notify the data exporter and, where
possible, the data subject promptly (if necessary with the help of the
data exporter) if it:
\(i\) receives a legally binding request from a public authority,
including judicial authorities, under the laws of the country of
destination for the disclosure of personal data transferred pursuant
to these Clauses; such notification shall include information about
the personal data requested, the requesting authority, the legal basis
for the request and the response provided; or
\(ii\) becomes aware of any direct access by public authorities to
personal data transferred pursuant to these Clauses in accordance with
the laws of the country of destination; such notification shall
include all information available to the importer.
\(b\) If the data importer is prohibited from notifying the data
exporter and/or the data subject under the laws of the country of
destination, the data importer agrees to use its best efforts to
obtain a waiver of the prohibition, with a view to communicating as
much information as possible, as soon as possible. The data importer
agrees to document its best efforts in order to be able to demonstrate
them on request of the data exporter.
\(c\) Where permissible under the laws of the country of destination,
the data importer agrees to provide the data exporter, at regular
intervals for the duration of the contract, with as much relevant
information as possible on the requests received (in particular,
number of requests, type of data requested, requesting authority/ies,
whether requests have been challenged and the outcome of such
challenges, etc.).
\(d\) The data importer agrees to preserve the information pursuant to
paragraphs (a) to (c) for the duration of the contract and make it
available to the competent supervisory authority on request.
\(e\) Paragraphs (a) to (c) are without prejudice to the obligation of
the data importer pursuant to Clause 14(e) and Clause 16 to inform the
data exporter promptly where it is unable to comply with these
Clauses.
**15.2 Review of legality and data minimisation**
\(a\) The data importer agrees to review the legality of the request
for disclosure, in particular whether it remains within the powers
granted to the requesting public authority, and to challenge the
request if, after careful assessment, it concludes that there are
reasonable grounds to consider that the request is unlawful under the
laws of the country of destination, applicable obligations under
international law and principles of international comity. The data
importer shall, under the same conditions, pursue possibilities of
appeal. When challenging a request, the data importer shall seek
interim measures with a view to suspending the effects of the request
until the competent judicial authority has decided on its merits. It
shall not disclose the personal data requested until required to do so
under the applicable procedural rules. These requirements are without
prejudice to the obligations of the data importer under Clause 14(e).
\(b\) The data importer agrees to document its legal assessment and
any challenge to the request for disclosure and, to the extent
permissible under the laws of the country of destination, make the
documentation available to the data exporter. It shall also make it
available to the competent supervisory authority on request.
\(c\) The data importer agrees to provide the minimum amount of
information permissible when responding to a request for disclosure,
based on a reasonable interpretation of the request.
**SECTION IV -- FINAL PROVISIONS**
**_Clause 16_**
**Non-compliance with the Clauses and termination**
\(a\) The data importer shall promptly inform the data exporter if it
is unable to comply with these Clauses, for whatever reason.
\(b\) In the event that the data importer is in breach of these
Clauses or unable to comply with these Clauses, the data exporter
shall suspend the transfer of personal data to the data importer until
compliance is again ensured or the contract is terminated. This is
without prejudice to Clause 14(f).
\(c\) The data exporter shall be entitled to terminate the contract,
insofar as it concerns the processing of personal data under these
Clauses, where:
\(i\) the data exporter has suspended the transfer of personal data to
the data importer pursuant to paragraph (b) and compliance with these
Clauses is not restored within a reasonable time and in any event
within one month of suspension;
\(ii\) the data importer is in substantial or persistent breach of these
Clauses; or
\(iii\) the data importer fails to comply with a binding decision of a
competent court or supervisory authority regarding its obligations
under these Clauses.
In these cases, it shall inform the competent supervisory authority of
such non-compliance. Where the contract involves more than two
Parties, the data exporter may exercise this right to termination only
with respect to the relevant Party, unless the Parties have agreed
otherwise.
\(d\) Personal data that has been transferred prior to the termination
of the contract pursuant to paragraph (c) shall at the choice of the
data exporter immediately be returned to the data exporter or deleted
in its entirety. The same shall apply to any copies of the data. The
data importer shall certify the deletion of the data to the data
exporter. Until the data is deleted or returned, the data importer
shall continue to ensure compliance with these Clauses. In case of
local laws applicable to the data importer that prohibit the return or
deletion of the transferred personal data, the data importer warrants
that it will continue to ensure compliance with these Clauses and will
only process the data to the extent and for as long as required under
that local law.
\(e\) Either Party may revoke its agreement to be bound by these
Clauses where (i) the European Commission adopts a decision pursuant
to Article 45(3) of Regulation (EU) 2016/679 that covers the transfer
of personal data to which these Clauses apply; or (ii) Regulation (EU)
2016/679 becomes part of the legal framework of the country to which
the personal data is transferred. This is without prejudice to other
obligations applying to the processing in question under Regulation
(EU) 2016/679.
**_Clause 17_**
**Governing law**
These Clauses shall be governed by the law of one of the EU Member
States, provided such law allows for third-party beneficiary rights. The
Parties agree that this shall be the law of \_\_\_\_\_\_\_ (specify
Member State).\]
**_Clause 18_**
**Choice of forum and jurisdiction**
\(a\) Any dispute arising from these Clauses shall be resolved by the
courts of an EU Member State.
\(b\) The Parties agree that those shall be the courts of \_\_\_\_\_
(_specify Member State_).
\(c\) A data subject may also bring legal proceedings against the data
exporter and/or data importer before the courts of the Member State in
which he/she has his/her habitual residence.
\(d\) The Parties agree to submit themselves to the jurisdiction of such
courts.
## Annex 4B: EU SCC Module 3 (Processor to Processor)
**Note:** If there are actual differences between the official EU SCC Module and this version below, the official EU SCC Module prevails.
---
STANDARD CONTRACTUAL CLAUSES
Processor to Processor
**SECTION I**
***Clause 1***
**Purpose and scope**
(a) The purpose of these standard contractual clauses is to ensure compliance with the requirements of Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation) ([^6]) for the transfer of personal data to a third country.
(b) The Parties:
(i) the natural or legal person(s), public authority/ies, agency/ies or other body/ies (hereinafter ‘entity/ies’) transferring the personal data, as listed in Annex I.A (hereinafter each ‘data exporter’), and
(ii) the entity/ies in a third country receiving the personal data from the data exporter, directly or indirectly via another entity also Party to these Clauses, as listed in Annex I.A (hereinafter each ‘data importer’)
have agreed to these standard contractual clauses (hereinafter: ‘Clauses’).
(c) These Clauses apply with respect to the transfer of personal data as specified in Annex I.B.
(d) The Appendix to these Clauses containing the Annexes referred to therein forms an integral part of these Clauses.
***Clause 2***
**Effect and invariability of the Clauses**
(a) These Clauses set out appropriate safeguards, including enforceable data subject rights and effective legal remedies, pursuant to Article 46(1) and Article 46(2)(c) of Regulation (EU) 2016/679 and, with respect to data transfers from controllers to processors and/or processors to processors, standard contractual clauses pursuant to Article 28(7) of Regulation (EU) 2016/679, provided they are not modified, except to select the appropriate Module(s) or to add or update information in the Appendix. This does not prevent the Parties from including the standard contractual clauses laid down in these Clauses in a wider contract and/or to add other clauses or additional safeguards, provided that they do not contradict, directly or indirectly, these Clauses or prejudice the fundamental rights or freedoms of data subjects.
(b) These Clauses are without prejudice to obligations to which the data exporter is subject by virtue of Regulation (EU) 2016/679.
***Clause 3***
**Third-party beneficiaries**
(a) Data subjects may invoke and enforce these Clauses, as third-party beneficiaries, against the data exporter and/or data importer, with the following exceptions:
(i) Clause 1, Clause 2, Clause 3, Clause 6, Clause 7;
(ii) Clause 8.1(a), (c) and (d) and Clause 8.9(a), (c), (d), (e), (f) and (g);
(iii) Clause 9(a), (c), (d) and (e);
(iv) Clause 12(a), (d) and (f);
(v) Clause 13;
(vi) Clause 15.1(c), (d) and (e);
(vii) Clause 16(e);
(viii) Clause 18(a) and (b).
(b) Paragraph (a) is without prejudice to rights of data subjects under Regulation (EU) 2016/679.
***Clause 4***
**Interpretation**
(a) Where these Clauses use terms that are defined in Regulation (EU) 2016/679, those terms shall have the same meaning as in that Regulation.
(b) These Clauses shall be read and interpreted in the light of the provisions of Regulation (EU) 2016/679.
(c) These Clauses shall not be interpreted in a way that conflicts with rights and obligations provided for in Regulation (EU) 2016/679.
***Clause 5***
**Hierarchy**
In the event of a contradiction between these Clauses and the provisions of related agreements between the Parties, existing at the time these Clauses are agreed or entered into thereafter, these Clauses shall prevail.
***Clause 6***
**Description of the transfer(s)**
The details of the transfer(s), and in particular the categories of personal data that are transferred and the purpose(s) for which they are transferred, are specified in Annex I.B.
***Clause 7 – Optional***
**Docking clause**
(a) An entity that is not a Party to these Clauses may, with the agreement of the Parties, accede to these Clauses at any time, either as a data exporter or as a data importer, by completing the Appendix and signing Annex I.A.
(b) Once it has completed the Appendix and signed Annex I.A, the acceding entity shall become a Party to these Clauses and have the rights and obligations of a data exporter or data importer in accordance with its designation in Annex I.A.
(c) The acceding entity shall have no rights or obligations arising under these Clauses from the period prior to becoming a Party.
**SECTION II – OBLIGATIONS OF THE PARTIES**
***Clause 8***
**Data protection safeguards**
The data exporter warrants that it has used reasonable efforts to determine that the data importer is able, through the implementation of appropriate technical and organisational measures, to satisfy its obligations under these Clauses.
**8.1 Instructions**
(a) The data exporter has informed the data importer that it acts as processor under the instructions of its controller(s), which the data exporter shall make available to the data importer prior to processing.
(b) The data importer shall process the personal data only on documented instructions from the controller, as communicated to the data importer by the data exporter, and any additional documented instructions from the data exporter. Such additional instructions shall not conflict with the instructions from the controller. The controller or data exporter may give further documented instructions regarding the data processing throughout the duration of the contract.
(c) The data importer shall immediately inform the data exporter if it is unable to follow those instructions. Where the data importer is unable to follow the instructions from the controller, the data exporter shall immediately notify the controller.
(d) The data exporter warrants that it has imposed the same data protection obligations on the data importer as set out in the contract or other legal act under Union or Member State law between the controller and the data exporter ([^7]).
**8.2 Purpose limitation**
The data importer shall process the personal data only for the specific purpose(s) of the transfer, as set out in Annex I.B., unless on further instructions from the controller, as communicated to the data importer by the data exporter, or from the data exporter.
**8.3 Transparency**
On request, the data exporter shall make a copy of these Clauses, including the Appendix as completed by the Parties, available to the data subject free of charge. To the extent necessary to protect business secrets or other confidential information, including personal data, the data exporter may redact part of the text of the Appendix prior to sharing a copy, but shall provide a meaningful summary where the data subject would otherwise not be able to understand its content or exercise his/her rights. On request, the Parties shall provide the data subject with the reasons for the redactions, to the extent possible without revealing the redacted information.
**8.4 Accuracy**
If the data importer becomes aware that the personal data it has received is inaccurate, or has become outdated, it shall inform the data exporter without undue delay. In this case, the data importer shall cooperate with the data exporter to rectify or erase the data.
**8.5 Duration of processing and erasure or return of data**
Processing by the data importer shall only take place for the duration specified in Annex I.B. After the end of the provision of the processing services, the data importer shall, at the choice of the data exporter, delete all personal data processed on behalf of the controller and certify to the data exporter that it has done so, or return to the data exporter all personal data processed on its behalf and delete existing copies. Until the data is deleted or returned, the data importer shall continue to ensure compliance with these Clauses. In case of local laws applicable to the data importer that prohibit return or deletion of the personal data, the data importer warrants that it will continue to ensure compliance with these Clauses and will only process it to the extent and for as long as required under that local law. This is without prejudice to Clause 14, in particular the requirement for the data importer under Clause 14(e) to notify the data exporter throughout the duration of the contract if it has reason to believe that it is or has become subject to laws or practices not in line with the requirements under Clause 14(a).
**8.6 Security of processing**
(a) The data importer and, during transmission, also the data exporter shall implement appropriate technical and organisational measures to ensure the security of the data, including protection against a breach of security leading to accidental or unlawful destruction, loss, alteration, unauthorised disclosure or access to that data (hereinafter ‘personal data breach’). In assessing the appropriate level of security, they shall take due account of the state of the art, the costs of implementation, the nature, scope, context and purpose(s) of processing and the risks involved in the processing for the data subject. The Parties shall in particular consider having recourse to encryption or pseudonymisation, including during transmission, where the purpose of processing can be fulfilled in that manner. In case of pseudonymisation, the additional information for attributing the personal data to a specific data subject shall, where possible, remain under the exclusive control of the data exporter or the controller. In complying with its obligations under this paragraph, the data importer shall at least implement the technical and organisational measures specified in Annex II. The data importer shall carry out regular checks to ensure that these measures continue to provide an appropriate level of security.
(b) The data importer shall grant access to the data to members of its personnel only to the extent strictly necessary for the implementation, management and monitoring of the contract. It shall ensure that persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality.
(c) In the event of a personal data breach concerning personal data processed by the data importer under these Clauses, the data importer shall take appropriate measures to address the breach, including measures to mitigate its adverse effects. The data importer shall also notify, without undue delay, the data exporter and, where appropriate and feasible, the controller after having become aware of the breach. Such notification shall contain the details of a contact point where more information can be obtained, a description of the nature of the breach (including, where possible, categories and approximate number of data subjects and personal data records concerned), its likely consequences and the measures taken or proposed to address the data breach, including measures to mitigate its possible adverse effects. Where, and in so far as, it is not possible to provide all information at the same time, the initial notification shall contain the information then available and further information shall, as it becomes available, subsequently be provided without undue delay.
(d) The data importer shall cooperate with and assist the data exporter to enable the data exporter to comply with its obligations under Regulation (EU) 2016/679, in particular to notify its controller so that the latter may in turn notify the competent supervisory authority and the affected data subjects, taking into account the nature of processing and the information available to the data importer.
**8.7 Sensitive data**
Where the transfer involves personal data revealing racial or ethnic origin, political opinions, religious or philosophical beliefs, or trade union membership, genetic data, or biometric data for the purpose of uniquely identifying a natural person, data concerning health or a person’s sex life or sexual orientation, or data relating to criminal convictions and offences (hereinafter ‘sensitive data’), the data importer shall apply the specific restrictions and/or additional safeguards set out in Annex I.B.
**8.8 Onward transfers**
The data importer shall only disclose the personal data to a third party on documented instructions from the controller, as communicated to the data importer by the data exporter. In addition, the data may only be disclosed to a third party located outside the European Union ([^8]) (in the same country as the data importer or in another third country, hereinafter ‘onward transfer’) if the third party is or agrees to be bound by these Clauses, under the appropriate Module, or if:
(i) the onward transfer is to a country benefitting from an adequacy decision pursuant to Article 45 of Regulation (EU) 2016/679 that covers the onward transfer;
(ii) the third party otherwise ensures appropriate safeguards pursuant to Articles 46 or 47 of Regulation (EU) 2016/679;
(iii) the onward transfer is necessary for the establishment, exercise or defence of legal claims in the context of specific administrative, regulatory or judicial proceedings; or
(iv) the onward transfer is necessary in order to protect the vital interests of the data subject or of another natural person.
Any onward transfer is subject to compliance by the data importer with all the other safeguards under these Clauses, in particular purpose limitation.
**8.9 Documentation and compliance**
(a) The data importer shall promptly and adequately deal with enquiries from the data exporter or the controller that relate to the processing under these Clauses.
(b) The Parties shall be able to demonstrate compliance with these Clauses. In particular, the data importer shall keep appropriate documentation on the processing activities carried out on behalf of the controller.
(c) The data importer shall make all information necessary to demonstrate compliance with the obligations set out in these Clauses available to the data exporter, which shall provide it to the controller.
(d) The data importer shall allow for and contribute to audits by the data exporter of the processing activities covered by these Clauses, at reasonable intervals or if there are indications of non-compliance. The same shall apply where the data exporter requests an audit on instructions of the controller. In deciding on an audit, the data exporter may take into account relevant certifications held by the data importer.
(e) Where the audit is carried out on the instructions of the controller, the data exporter shall make the results available to the controller.
(f) The data exporter may choose to conduct the audit by itself or mandate an independent auditor. Audits may include inspections at the premises or physical facilities of the data importer and shall, where appropriate, be carried out with reasonable notice.
(g) The Parties shall make the information referred to in paragraphs (b) and (c), including the results of any audits, available to the competent supervisory authority on request.
***Clause 9***
**Use of sub-processors**
(a) OPTION 1: SPECIFIC PRIOR AUTHORISATION The data importer shall not sub-contract any of its processing activities performed on behalf of the data exporter under these Clauses to a sub-processor without the prior specific written authorisation of the controller. The data importer shall submit the request for specific authorisation at least [*Specify time period*] prior to the engagement of the sub-processor, together with the information necessary to enable the controller to decide on the authorisation. It shall inform the data exporter of such engagement. The list of sub-processors already authorised by the controller can be found in Annex III. The Parties shall keep Annex III up to date.
OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the controller’s general authorisation for the engagement of sub-processor(s) from an agreed list. The data importer shall specifically inform the controller in writing of any intended changes to that list through the addition or replacement of sub-processors at least [*Specify time period*] in advance, thereby giving the controller sufficient time to be able to object to such changes prior to the engagement of the sub-processor(s). The data importer shall provide the controller with the information necessary to enable the controller to exercise its right to object. The data importer shall inform the data exporter of the engagement of the sub-processor(s).
(b) Where the data importer engages a sub-processor to carry out specific processing activities (on behalf of the controller), it shall do so by way of a written contract that provides for, in substance, the same data protection obligations as those binding the data importer under these Clauses, including in terms of third-party beneficiary rights for data subjects. ([^9]) The Parties agree that, by complying with this Clause, the data importer fulfils its obligations under Clause 8.8. The data importer shall ensure that the sub-processor complies with the obligations to which the data importer is subject pursuant to these Clauses.
(c) The data importer shall provide, at the data exporter’s or controller’s request, a copy of such a sub-processor agreement and any subsequent amendments. To the extent necessary to protect business secrets or other confidential information, including personal data, the data importer may redact the text of the agreement prior to sharing a copy.
(d) The data importer shall remain fully responsible to the data exporter for the performance of the sub-processor’s obligations under its contract with the data importer. The data importer shall notify the data exporter of any failure by the sub-processor to fulfil its obligations under that contract.
(e) The data importer shall agree a third-party beneficiary clause with the sub-processor whereby – in the event the data importer has factually disappeared, ceased to exist in law or has become insolvent – the data exporter shall have the right to terminate the sub-processor contract and to instruct the sub-processor to erase or return the personal data.
***Clause 10***
**Data subject rights**
(a) The data importer shall promptly notify the data exporter and, where appropriate, the controller of any request it has received from a data subject, without responding to that request unless it has been authorised to do so by the controller.
(b) The data importer shall assist, where appropriate in cooperation with the data exporter, the controller in fulfilling its obligations to respond to data subjects’ requests for the exercise of their rights under Regulation (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable. In this regard, the Parties shall set out in Annex II the appropriate technical and organisational measures, taking into account the nature of the processing, by which the assistance shall be provided, as well as the scope and the extent of the assistance required.
(c) In fulfilling its obligations under paragraphs (a) and (b), the data importer shall comply with the instructions from the controller, as communicated by the data exporter.
***Clause 11***
**Redress**
(a) The data importer shall inform data subjects in a transparent and easily accessible format, through individual notice or on its website, of a contact point authorised to handle complaints. It shall deal promptly with any complaints it receives from a data subject.
[OPTION: The data importer agrees that data subjects may also lodge a complaint with an independent dispute resolution body ([^10]) at no cost to the data subject. It shall inform the data subjects, in the manner set out in paragraph (a), of such redress mechanism and that they are not required to use it, or follow a particular sequence in seeking redress.]
` `(b) In case of a dispute between a data subject and one of the Parties as regards compliance with these Clauses, that Party shall use its best efforts to resolve the issue amicably in a timely fashion. The Parties shall keep each other informed about such disputes and, where appropriate, cooperate in resolving them.
(c) Where the data subject invokes a third-party beneficiary right pursuant to Clause 3, the data importer shall accept the decision of the data subject to:
(i) lodge a complaint with the supervisory authority in the Member State of his/her habitual residence or place of work, or the competent supervisory authority pursuant to Clause 13;
(ii) refer the dispute to the competent courts within the meaning of Clause 18.
(d) The Parties accept that the data subject may be represented by a not-for-profit body, organisation or association under the conditions set out in Article 80(1) of Regulation (EU) 2016/679.
(e) The data importer shall abide by a decision that is binding under the applicable EU or Member State law.
(f) The data importer agrees that the choice made by the data subject will not prejudice his/her substantive and procedural rights to seek remedies in accordance with applicable laws.
***Clause 12***
**Liability**
(a) Each Party shall be liable to the other Party/ies for any damages it causes the other Party/ies by any breach of these Clauses.
(b) The data importer shall be liable to the data subject, and the data subject shall be entitled to receive compensation, for any material or non-material damages the data importer or its sub-processor causes the data subject by breaching the third-party beneficiary rights under these Clauses.
(c) Notwithstanding paragraph (b), the data exporter shall be liable to the data subject, and the data subject shall be entitled to receive compensation, for any material or non-material damages the data exporter or the data importer (or its sub-processor) causes the data subject by breaching the third-party beneficiary rights under these Clauses. This is without prejudice to the liability of the data exporter and, where the data exporter is a processor acting on behalf of a controller, to the liability of the controller under Regulation (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable.
(d) The Parties agree that if the data exporter is held liable under paragraph (c) for damages caused by the data importer (or its sub-processor), it shall be entitled to claim back from the data importer that part of the compensation corresponding to the data importer’s responsibility for the damage.
(e) Where more than one Party is responsible for any damage caused to the data subject as a result of a breach of these Clauses, all responsible Parties shall be jointly and severally liable and the data subject is entitled to bring an action in court against any of these Parties.
(f) The Parties agree that if one Party is held liable under paragraph (e), it shall be entitled to claim back from the other Party/ies that part of the compensation corresponding to its/their responsibility for the damage.
(g) The data importer may not invoke the conduct of a sub-processor to avoid its own liability.
***Clause 13***
**Supervision**
1) [Where the data exporter is established in an EU Member State:] The supervisory authority with responsibility for ensuring compliance by the data exporter with Regulation (EU) 2016/679 as regards the data transfer, as indicated in Annex I.C, shall act as competent supervisory authority.
[Where the data exporter is not established in an EU Member State, but falls within the territorial scope of application of Regulation (EU) 2016/679 in accordance with its Article 3(2) and has appointed a representative pursuant to Article 27(1) of Regulation (EU) 2016/679:] The supervisory authority of the Member State in which the representative within the meaning of Article 27(1) of Regulation (EU) 2016/679 is established, as indicated in Annex I.C, shall act as competent supervisory authority.
[Where the data exporter is not established in an EU Member State, but falls within the territorial scope of application of Regulation (EU) 2016/679 in accordance with its Article 3(2) without however having to appoint a representative pursuant to Article 27(2) of Regulation (EU) 2016/679:] The supervisory authority of one of the Member States in which the data subjects whose personal data is transferred under these Clauses in relation to the offering of goods or services to them, or whose behaviour is monitored, are located, as indicated in Annex I.C, shall act as competent supervisory authority.
(b) The data importer agrees to submit itself to the jurisdiction of and cooperate with the competent supervisory authority in any procedures aimed at ensuring compliance with these Clauses. In particular, the data importer agrees to respond to enquiries, submit to audits and comply with the measures adopted by the supervisory authority, including remedial and compensatory measures. It shall provide the supervisory authority with written confirmation that the necessary actions have been taken.
**SECTION III – LOCAL LAWS AND OBLIGATIONS IN CASE OF ACCESS BY PUBLIC AUTHORITIES**
***Clause 14***
**Local laws and practices affecting compliance with the Clauses**
(a) The Parties warrant that they have no reason to believe that the laws and practices in the third country of destination applicable to the processing of the personal data by the data importer, including any requirements to disclose personal data or measures authorising access by public authorities, prevent the data importer from fulfilling its obligations under these Clauses. This is based on the understanding that laws and practices that respect the essence of the fundamental rights and freedoms and do not exceed what is necessary and proportionate in a democratic society to safeguard one of the objectives listed in Article 23(1) of Regulation (EU) 2016/679, are not in contradiction with these Clauses.
(b) The Parties declare that in providing the warranty in paragraph (a), they have taken due account in particular of the following elements:
(i) the specific circumstances of the transfer, including the length of the processing chain, the number of actors involved and the transmission channels used; intended onward transfers; the type of recipient; the purpose of processing; the categories and format of the transferred personal data; the economic sector in which the transfer occurs; the storage location of the data transferred;
(ii) the laws and practices of the third country of destination– including those requiring the disclosure of data to public authorities or authorising access by such authorities – relevant in light of the specific circumstances of the transfer, and the applicable limitations and safeguards ([^11]);
(iii) any relevant contractual, technical or organisational safeguards put in place to supplement the safeguards under these Clauses, including measures applied during transmission and to the processing of the personal data in the country of destination.
(c) The data importer warrants that, in carrying out the assessment under paragraph (b), it has made its best efforts to provide the data exporter with relevant information and agrees that it will continue to cooperate with the data exporter in ensuring compliance with these Clauses.
(d) The Parties agree to document the assessment under paragraph (b) and make it available to the competent supervisory authority on request.
(e) The data importer agrees to notify the data exporter promptly if, after having agreed to these Clauses and for the duration of the contract, it has reason to believe that it is or has become subject to laws or practices not in line with the requirements under paragraph (a), including following a change in the laws of the third country or a measure (such as a disclosure request) indicating an application of such laws in practice that is not in line with the requirements in paragraph (a). The data exporter shall forward the notification to the controller.
(f) Following a notification pursuant to paragraph (e), or if the data exporter otherwise has reason to believe that the data importer can no longer fulfil its obligations under these Clauses, the data exporter shall promptly identify appropriate measures (e.g. technical or organisational measures to ensure security and confidentiality) to be adopted by the data exporter and/or data importer to address the situation, if appropriate in consultation with the controller. The data exporter shall suspend the data transfer if it considers that no appropriate safeguards for such transfer can be ensured, or if instructed by the controller or the competent supervisory authority to do so. In this case, the data exporter shall be entitled to terminate the contract, insofar as it concerns the processing of personal data under these Clauses. If the contract involves more than two Parties, the data exporter may exercise this right to termination only with respect to the relevant Party, unless the Parties have agreed otherwise. Where the contract is terminated pursuant to this Clause, Clause 16(d) and (e) shall apply.
***Clause 15***
**Obligations of the data importer in case of access by public authorities**
**15.1 Notification**
(a) The data importer agrees to notify the data exporter and, where possible, the data subject promptly (if necessary with the help of the data exporter) if it:
(i) receives a legally binding request from a public authority, including judicial authorities, under the laws of the country of destination for the disclosure of personal data transferred pursuant to these Clauses; such notification shall include information about the personal data requested, the requesting authority, the legal basis for the request and the response provided; or
(ii) becomes aware of any direct access by public authorities to personal data transferred pursuant to these Clauses in accordance with the laws of the country of destination; such notification shall include all information available to the importer.
The data exporter shall forward the notification to the controller.
1) If the data importer is prohibited from notifying the data exporter and/or the data subject under the laws of the country of destination, the data importer agrees to use its best efforts to obtain a waiver of the prohibition, with a view to communicating as much information as possible, as soon as possible. The data importer agrees to document its best efforts in order to be able to demonstrate them on request of the data exporter.
1) Where permissible under the laws of the country of destination, the data importer agrees to provide the data exporter, at regular intervals for the duration of the contract, with as much relevant information as possible on the requests received (in particular, number of requests, type of data requested, requesting authority/ies, whether requests have been challenged and the outcome of such challenges, etc.). The data exporter shall forward the information to the controller.
1) The data importer agrees to preserve the information pursuant to paragraphs (a) to (c) for the duration of the contract and make it available to the competent supervisory authority on request.
1) Paragraphs (a) to (c) are without prejudice to the obligation of the data importer pursuant to Clause 14(e) and Clause 16 to inform the data exporter promptly where it is unable to comply with these Clauses.
**15.2 Review of legality and data minimization**
(a) The data importer agrees to review the legality of the request for disclosure, in particular whether it remains within the powers granted to the requesting public authority, and to challenge the request if, after careful assessment, it concludes that there are reasonable grounds to consider that the request is unlawful under the laws of the country of destination, applicable obligations under international law and principles of international comity. The data importer shall, under the same conditions, pursue possibilities of appeal. When challenging a request, the data importer shall seek interim measures with a view to suspending the effects of the request until the competent judicial authority has decided on its merits. It shall not disclose the personal data requested until required to do so under the applicable procedural rules. These requirements are without prejudice to the obligations of the data importer under Clause 14(e).
(b) The data importer agrees to document its legal assessment and any challenge to the request for disclosure and, to the extent permissible under the laws of the country of destination, make the documentation available to the data exporter. It shall also make it available to the competent supervisory authority on request. The data exporter shall make the assessment available to the controller.
(c) The data importer agrees to provide the minimum amount of information permissible when responding to a request for disclosure, based on a reasonable interpretation of the request.
**SECTION IV – FINAL PROVISIONS**
***Clause 16***
**Non-compliance with the Clauses and termination**
(a) The data importer shall promptly inform the data exporter if it is unable to comply with these Clauses, for whatever reason.
(b) In the event that the data importer is in breach of these Clauses or unable to comply with these Clauses, the data exporter shall suspend the transfer of personal data to the data importer until compliance is again ensured or the contract is terminated. This is without prejudice to Clause 14(f).
(c) The data exporter shall be entitled to terminate the contract, insofar as it concerns the processing of personal data under these Clauses, where:
(i) the data exporter has suspended the transfer of personal data to the data importer pursuant to paragraph (b) and compliance with these Clauses is not restored within a reasonable time and in any event within one month of suspension;
(ii) the data importer is in substantial or persistent breach of these Clauses; or
(iii) the data importer fails to comply with a binding decision of a competent court or supervisory authority regarding its obligations under these Clauses.
In these cases, it shall inform the competent supervisory authority and the controller of such non-compliance. Where the contract involves more than two Parties, the data exporter may exercise this right to termination only with respect to the relevant Party, unless the Parties have agreed otherwise.
(d) Personal data that has been transferred prior to the termination of the contract pursuant to paragraph (c) shall at the choice of the data exporter immediately be returned to the data exporter or deleted in its entirety. The same shall apply to any copies of the data. The data importer shall certify the deletion of the data to the data exporter. Until the data is deleted or returned, the data importer shall continue to ensure compliance with these Clauses. In case of local laws applicable to the data importer that prohibit the return or deletion of the transferred personal data, the data importer warrants that it will continue to ensure compliance with these Clauses and will only process the data to the extent and for as long as required under that local law.
(e) Either Party may revoke its agreement to be bound by these Clauses where (i) the European Commission adopts a decision pursuant to Article 45(3) of Regulation (EU) 2016/679 that covers the transfer of personal data to which these Clauses apply; or (ii) Regulation (EU) 2016/679 becomes part of the legal framework of the country to which the personal data is transferred. This is without prejudice to other obligations applying to the processing in question under Regulation (EU) 2016/679.
***Clause 17***
**Governing law**
These Clauses shall be governed by the law of one of the EU Member States, provided such law allows for third-party beneficiary rights. The Parties agree that this shall be the law of \_\_\_\_\_\_\_ (*specify Member State*).]
***Clause 18***
**Choice of forum and jurisdiction**
(a) Any dispute arising from these Clauses shall be resolved by the courts of an EU Member State.
(b) The Parties agree that those shall be the courts of \_\_\_\_\_ (specify Member State).
(c) A data subject may also bring legal proceedings against the data exporter and/or data importer before the courts of the Member State in which he/she has his/her habitual residence.
(d) The Parties agree to submit themselves to the jurisdiction of such courts
## Annex 5: UK SCC (Controller to Processor)
**Note:** If there are actual differences between the official UK SCC Module and this version below, the official EU SCC Module prevails.
---
[CONTROLLER TO PROCESSOR MODEL CLAUSES: SET II]
Commission Decision C(2010)593
Standard Contractual Clauses (processors)
For the purposes of Article 26(2) UK GDPR for the transfer of personal data to processors established in third countries which do not ensure an adequate level of data protection
Name of the data exporting organisation: [Transferor]
(the data **exporter** )
And
Name of the data importing organisation: [Transferee]
(the data **importer** )
each a "party"; together "the parties",
HAVE AGREED on the following Contractual Clauses (the Clauses) in order to adduce adequate safeguards with respect to the protection of privacy and fundamental rights and freedoms of individuals for the transfer by the data exporter to the data importer of the personal data specified in Appendix 1.
_Clause 1_
_ **Definitions** _
For the purposes of the Clauses:
(a)'personal data', 'special categories of data', 'process/processing', 'controller', 'processor', 'data subject' and 'supervisory authority' shall have the same meaning as in the UK GDPR;
(b) 'the data exporter' means the controller who transfers the personal data;
(c) 'the data importer' means the processor who agrees to receive from the data exporter personal data intended for processing on his behalf after the transfer in accordance with his instructions and the terms of the Clauses and who is not subject to a third country's system ensuring adequate protection within the meaning of Article 25(1) UK GDPR;
(d) 'the subprocessor' means any processor engaged by the data importer or by any other subprocessor of the data importer who agrees to receive from the data importer or from any other subprocessor of the data importer personal data exclusively intended for processing activities to be carried out on behalf of the data exporter after the transfer in accordance with his instructions, the terms of the Clauses and the terms of the written subcontract;
(e) 'the applicable data protection law **'** means the legislation protecting the fundamental rights and freedoms of individuals and, in particular, their right to privacy with respect to the processing of personal data applicable to a data controller in the United Kingdom;
(f)'technical and organisational security measures' means those measures aimed at protecting personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing.
_Clause 2_
_ **Details of the transfer** _
The details of the transfer and in particular the special categories of personal data where applicable are specified in Appendix 1 which forms an integral part of the Clauses.
_Clause 3_
_ **Third-party beneficiary clause** _
1. The data subject can enforce against the data exporter this Clause, Clause 4(b) to (i), Clause 5(a) to (e), and (g) to (j), Clause 6(1) and (2), Clause 7, Clause 8(2), and Clauses 9 to 12 as third-party beneficiary.
2. The data subject can enforce against the data importer this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where the data exporter has factually disappeared or has ceased to exist in law unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law, as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity.
3. The data subject can enforce against the subprocessor this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
4. The parties do not object to a data subject being represented by an association or other body if the data subject so expressly wishes and if permitted by national law.
_Clause 4_
_ **Obligations of the data exporter** _
The data exporter agrees and warrants:
(a) that the processing, including the transfer itself, of the personal data has been and will continue to be carried out in accordance with the relevant provisions of the applicable data protection law (and, where applicable, has been notified to the relevant authorities in the United Kingdom) and does not violate the relevant provisions of the United Kingdom;
(b) that it has instructed and throughout the duration of the personal data processing services will instruct the data importer to process the personal data transferred only on the data exporter's behalf and in accordance with the applicable data protection law and the Clauses;
(c) that the data importer will provide sufficient guarantees in respect of the technical and organisational security measures specified in Appendix 2 to this contract;
(d) that after assessment of the requirements of the applicable data protection law, the security measures are appropriate to protect personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing, and that these measures ensure a level of security appropriate to the risks presented by the processing and the nature of the data to be protected having regard to the state of the art and the cost of their implementation;
(e) that it will ensure compliance with the security measures;
(f) that, if the transfer involves special categories of data, the data subject has been informed or will be informed before, or as soon as possible after, the transfer that its data could be transmitted to a third country not providing adequate protection within the meaning of the UK GDPR;
(g) to forward any notification received from the data importer or any subprocessor pursuant to Clause 5(b) and Clause 8(3) to the data protection supervisory authority if the data exporter decides to continue the transfer or to lift the suspension;
(h) to make available to the data subjects upon request a copy of the Clauses, with the exception of Appendix 2, and a summary description of the security measures, as well as a copy of any contract for subprocessing services which has to be made in accordance with the Clauses, unless the Clauses or the contract contain commercial information, in which case it may remove such commercial information;
(i) that, in the event of subprocessing, the processing activity is carried out in accordance with Clause 11 by a subprocessor providing at least the same level of protection for the personal data and the rights of data subject as the data importer under the Clauses; and
(j) that it will ensure compliance with Clause 4(a) to (i).
_Clause 5_
_ **Obligations of the data importer [^12]** _
The data importer agrees and warrants:
(a) to process the personal data only on behalf of the data exporter and in compliance with its instructions and the Clauses; if it cannot provide such compliance for whatever reasons, it agrees to inform promptly the data exporter of its inability to comply, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(b) that it has no reason to believe that the legislation applicable to it prevents it from fulfilling the instructions received from the data exporter and its obligations under the contract and that in the event of a change in this legislation which is likely to have a substantial adverse effect on the warranties and obligations provided by the Clauses, it will promptly notify the change to the data exporter as soon as it is aware, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(c) that it has implemented the technical and organisational security measures specified in Appendix 2 before processing the personal data transferred;
(d) that it will promptly notify the data exporter about:
(i) any legally binding request for disclosure of the personal data by a law enforcement authority unless otherwise prohibited, such as a prohibition under criminal law to preserve the confidentiality of a law enforcement investigation,
(ii) any accidental or unauthorised access, and
(iii) any request received directly from the data subjects without responding to that request, unless it has been otherwise authorised to do so;
(e) to deal promptly and properly with all inquiries from the data exporter relating to its processing of the personal data subject to the transfer and to abide by the advice of the supervisory authority with regard to the processing of the data transferred;
(f) at the request of the data exporter to submit its data processing facilities for audit of the processing activities covered by the Clauses which shall be carried out by the data exporter or an inspection body composed of independent members and in possession of the required professional qualifications bound by a duty of confidentiality, selected by the data exporter, where applicable, in agreement with the supervisory authority;
(g) to make available to the data subject upon request a copy of the Clauses, or any existing contract for subprocessing, unless the Clauses or contract contain commercial information, in which case it may remove such commercial information, with the exception of Appendix 2 which shall be replaced by a summary description of the security measures in those cases where the data subject is unable to obtain a copy from the data exporter;
(h) that, in the event of subprocessing, it has previously informed the data exporter and obtained its prior written consent;
(i) that the processing services by the subprocessor will be carried out in accordance with Clause 11;
(j) to send promptly a copy of any subprocessor agreement it concludes under the Clauses to the data exporter.
_Clause 6_
_ **Liability** _
1. The parties agree that any data subject, who has suffered damage as a result of any breach of the obligations referred to in Clause 3 or in Clause 11 by any party or subprocessor is entitled to receive compensation from the data exporter for the damage suffered.
2. If a data subject is not able to bring a claim for compensation in accordance with paragraph 1 against the data exporter, arising out of a breach by the data importer or his subprocessor of any of their obligations referred to in Clause 3 or in Clause 11, because the data exporter has factually disappeared or ceased to exist in law or has become insolvent, the data importer agrees that the data subject may issue a claim against the data importer as if it were the data exporter, unless any successor entity has assumed the entire legal obligations of the data exporter by contract of by operation of law, in which case the data subject can enforce its rights against such entity.
The data importer may not rely on a breach by a subprocessor of its obligations in order to avoid its own liabilities.
3. If a data subject is not able to bring a claim against the data exporter or the data importer referred to in paragraphs 1 and 2, arising out of a breach by the subprocessor of any of their obligations referred to in Clause 3 or in Clause 11 because both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, the subprocessor agrees that the data subject may issue a claim against the data subprocessor with regard to its own processing operations under the Clauses as if it were the data exporter or the data importer, unless any successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law, in which case the data subject can enforce its rights against such entity. The liability of the subprocessor shall be limited to its own processing operations under the Clauses.
_Clause 7_
_ **Mediation and jurisdiction** _
1. The data importer agrees that if the data subject invokes against it third-party beneficiary rights and/or claims compensation for damages under the Clauses, the data importer will accept the decision of the data subject:
(a) to refer the dispute to mediation, by an independent person or, where applicable, by the supervisory authority;
(b) to refer the dispute to the courts in the United Kingdom.
2. The parties agree that the choice made by the data subject will not prejudice its substantive or procedural rights to seek remedies in accordance with other provisions of national or international law.
_Clause 8_
_ **Cooperation with supervisory authorities** _
1. The data exporter agrees to deposit a copy of this contract with the supervisory authority if it so requests or if such deposit is required under the applicable data protection law.
2. The parties agree that the supervisory authority has the right to conduct an audit of the data importer, and of any subprocessor, which has the same scope and is subject to the same conditions as would apply to an audit of the data exporter under the applicable data protection law.
3. The data importer shall promptly inform the data exporter about the existence of legislation applicable to it or any subprocessor preventing the conduct of an audit of the data importer, or any subprocessor, pursuant to paragraph 2. In such a case the data exporter shall be entitled to take the measures foreseen in Clause 5 (b).
_Clause 9_
_ **Governing Law** _
The Clauses shall be governed by the laws of England and Wales.
_Clause 10_
_ **Variation of the contract** _
The parties undertake not to vary or modify the Clauses. This does not preclude the parties from adding clauses on business related issues where required as long as they do not contradict the Clause.
_Clause 11_
_ **Subprocessing** _
1. The data importer shall not subcontract any of its processing operations performed on behalf of the data exporter under the Clauses without the prior written consent of the data exporter. Where the data importer subcontracts its obligations under the Clauses, with the consent of the data exporter, it shall do so only by way of a written agreement with the subprocessor which imposes the same obligations on the subprocessor as are imposed on the data importer under the Clauses[^13]. Where the subprocessor fails to fulfil its data protection obligations under such written agreement the data importer shall remain fully liable to the data exporter for the performance of the subprocessor's obligations under such agreement.
2. The prior written contract between the data importer and the subprocessor shall also provide for a third-party beneficiary clause as laid down in Clause 3 for cases where the data subject is not able to bring the claim for compensation referred to in paragraph 1 of Clause 6 against the data exporter or the data importer because they have factually disappeared or have ceased to exist in law or have become insolvent and no successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
3. The provisions relating to data protection aspects for subprocessing of the contract referred to in paragraph 1 shall be governed by the laws of England and Wales.
4. The data exporter shall keep a list of subprocessing agreements concluded under the Clauses and notified by the data importer pursuant to Clause 5 (j), which shall be updated at least once a year. The list shall be available to the data exporter's data protection supervisory authority.
_Clause 12_
_ **Obligation after the termination of personal data processing services** _
1. The parties agree that on the termination of the provision of data processing services, the data importer and the subprocessor shall, at the choice of the data exporter, return all the personal data transferred and the copies thereof to the data exporter or shall destroy all the personal data and certify to the data exporter that it has done so, unless legislation imposed upon the data importer prevents it from returning or destroying all or part of the personal data transferred. In that case, the data importer warrants that it will guarantee the confidentiality of the personal data transferred and will not actively process the personal data transferred anymore.
2. The data importer and the subprocessor warrant that upon request of the data exporter and/or of the supervisory authority, it will submit its data processing facilities for an audit of the measures referred to in paragraph 1.
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 1 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Data exporter**
The data exporter is (please specify briefly your activities relevant to the transfer):
xx
**Data importer**
The data importer is (please specify briefly activities relevant to the transfer):
xx
**Data subjects**
The personal data transferred concern the following categories of data subjects (please specify):
xx
**Categories of data**
The personal data transferred concern the following categories of data (please specify):
xx
**Special categories of data (if appropriate)**
The personal data transferred concern the following special categories of data (please specify):
xx
**Processing operations**
The personal data transferred will be subject to the following basic processing activities (please specify): [_insert_]
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 2 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Description of the technical and organisational security measures implemented by the data importer in accordance with Clauses 4(d) and 5(c):**
xx
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
---
[^1]: Where the data exporter is a processor subject to Regulation (EU) 2016/679 acting on behalf of a Union institution or body as controller, reliance on these Clauses when engaging another processor (sub-processing) not subject to Regulation (EU) 2016/679 also ensures compliance with Article 29(4) of Regulation (EU) 2018/1725 of the European Parliament and of the Council of 23 October 2018 on the protection of natural persons with regard to the processing of personal data by the Union institutions, bodies, offices and agencies and on the free movement of such data, and repealing Regulation (EC) No 45/2001 and Decision No 1247/2002/EC ([OJ L 295, 21.11.2018, p. 39](https://eur-lex.europa.eu/legal-content/EN/AUTO/?uri=OJ:L:2018:295:TOC)), to the extent these Clauses and the data protection obligations as set out in the contract or other legal act between the controller and the processor pursuant to Article 29(3) of Regulation (EU) 2018/1725 are aligned. This will in particular be the case where the controller and processor rely on the standard contractual clauses included in Decision 2021/915.
[^2]: The Agreement on the European Economic Area (EEA Agreement) provides for the extension of the European Union's internal market to the three EEA States Iceland, Liechtenstein and Norway. The Union data protection legislation, including Regulation (EU) 2016/679, is covered by the EEA Agreement and has been incorporated into Annex XI thereto. Therefore, any disclosure by the data importer to a third party located in the EEA does not qualify as an onward transfer for the purpose of these Clauses.
[^3]: This requirement may be satisfied by the sub-processor acceding to these Clauses under the appropriate Module, in accordance with Clause 7.
[^4]: The data importer may offer independent dispute resolution through an arbitration body only if it is established in a country that has ratified the New York Convention on Enforcement of Arbitration Awards.
[^5]: As regards the impact of such laws and practices on compliance with these Clauses, different elements may be considered as part of an overall assessment. Such elements may include relevant and documented practical experience with prior instances of requests for disclosure from public authorities, or the absence of such requests, covering a sufficiently representative time-frame. This refers in particular to internal records or other documentation, drawn up on a continuous basis in accordance with due diligence and certified at senior management level, provided that this information can be lawfully shared with third parties. Where this practical experience is relied upon to conclude that the data importer will not be prevented from complying with these Clauses, it needs to be supported by other relevant, objective elements, and it is for the Parties to consider carefully whether these elements together carry sufficient weight, in terms of their reliability and representativeness, to support this conclusion. In particular, the Parties have to take into account whether their practical experience is corroborated and not contradicted by publicly available or otherwise accessible, reliable information on the existence or absence of requests within the same sector and/or the application of the law in practice, such as case law and reports by independent oversight bodies.
[^6]: Where the data exporter is a processor subject to Regulation (EU) 2016/679 acting on behalf of a Union institution or body as controller, reliance on these Clauses when engaging another processor (sub-processing) not subject to Regulation (EU) 2016/679 also ensures compliance with Article 29(4) of Regulation (EU) 2018/1725 of the European Parliament and of the Council of 23 October 2018 on the protection of natural persons with regard to the processing of personal data by the Union institutions, bodies, offices and agencies and on the free movement of such data, and repealing Regulation (EC) No 45/2001 and Decision No 1247/2002/EC (OJ L 295, 21.11.2018, p. 39), to the extent these Clauses and the data protection obligations as set out in the contract or other legal act between the controller and the processor pursuant to Article 29(3) of Regulation (EU) 2018/1725 are aligned. This will in particular be the case where the controller and processor rely on the standard contractual clauses included in Decision 2021/915.
[^7]: See Article 28(4) of Regulation (EU) 2016/679 and, where the controller is an EU institution or body, Article 29(4) of Regulation (EU) 2018/1725.
[^8]: The Agreement on the European Economic Area (EEA Agreement) provides for the extension of the European Union’s internal market to the three EEA States Iceland, Liechtenstein and Norway. The Union data protection legislation, including Regulation (EU) 2016/679, is covered by the EEA Agreement and has been incorporated into Annex XI thereto. Therefore, any disclosure by the data importer to a third party located in the EEA does not qualify as an onward transfer for the purposes of these Clauses.
[^9]: This requirement may be satisfied by the sub-processor acceding to these Clauses under the appropriate Module, in accordance with Clause 7.
[^10]: The data importer may offer independent dispute resolution through an arbitration body only if it is established in a country that has ratified the New York Convention on Enforcement of Arbitration Awards.
[^11]: As regards the impact of such laws and practices on compliance with these Clauses, different elements may be considered as part of an overall assessment. Such elements may include relevant and documented practical experience with prior instances of requests for disclosure from public authorities, or the absence of such requests, covering a sufficiently representative time-frame. This refers in particular to internal records or other documentation, drawn up on a continuous basis in accordance with due diligence and certified at senior management level, provided that this information can be lawfully shared with third parties. Where this practical experience is relied upon to conclude that the data importer will not be prevented from complying with these Clauses, it needs to be supported by other relevant, objective elements, and it is for the Parties to consider carefully whether these elements together carry sufficient weight, in terms of their reliability and representativeness, to support this conclusion. In particular, the Parties have to take into account whether their practical experience is corroborated and not contradicted by publicly available or otherwise accessible, reliable information on the existence or absence of requests within the same sector and/or the application of the law in practice, such as case law and reports by independent oversight bodies.
[^12]: Mandatory requirements of the national legislation applicable to the data importer which do not go beyond what is necessary in a democratic society on the basis of one of the interests listed in Article 13(1) UK GDPR, that is, if they constitute a necessary measure to safeguard national security, defence, public security, the prevention, investigation, detection and prosecution of criminal offences or of breaches of ethics for the regulated professions, an important economic or financial interest of the State or the protection of the data subject or the rights and freedoms of others, are not in contradiction with the standard contractual clauses. Some examples of such mandatory requirements which do not go beyond what is necessary in a democratic society are, inter alia, internationally recognised sanctions, tax-reporting requirements or anti-money-laundering reporting requirements.
[^13]: This requirement may be satisfied by the subprocessor co-signing the contract entered into between the data exporter and the data importer under this Decision.
---
## Data processing agreement (DPA) (effective 24 August 2022)
:::warning Superseded version
This is the **24 August 2022** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Data processing agreement (DPA)](/legal/dpa).
:::
:::tip Where to sign this document
Sign the Holistics Data Processing Agreement at: https://go.holistics.io/signdpa
:::
## Holistics Data Processing Agreement (DPA)
_Last Updated: 24 August 2022_
**Definitions**
"California Personal Information" means Personal Data that is subject to the protection of the CCPA.
"CCPA" means California Civil Code Sec. 1798.100 et seq. (also known as the California Consumer Privacy Act of 2018).
"Consumer", "Business", "Sell" and "Service Provider" shall have the meanings given to them in the CCPA.
"Customer" refers to the Customer on a paid subscription plan with Holistics as described in the Terms, and all of its Affiliates.
"Customer Data" or "Customer Database" refers to all data residing in the Customer's database(s) and data source(s) connected to Holistics by Customer.
Customer End Users means the employees of the Customer who have been invited to access the Holistics Subscription Service in their customer account, or are in contact with Holistics.
"Data Protection Laws" means all applicable worldwide legislation relating to data protection and privacy which applies to the respective party in the role of Processing Personal Data in question under the Agreement, including without limitation European Data Protection Laws (EU and UK GDPR), the US CCPA, the Swiss FDPA, the Singapore PDPA, and the data protection and privacy laws of Australia; in each case as amended, repealed, consolidated or replaced from time to time.
"Data Subject" means the individual to whom "Personal Data" relates.
"Database Metadata" refers to the following categories of metadata from the customers' database which includes broadly (but not limited to):
User credentials of data source(s), applied with the necessary security encryption before storing in Holistics database.
The metadata (e.g. names of schemas, tables, fields, model relationships descriptions) of the database tables, excluding physical data record entries.
The metadata of definitions of objects created within the Holistics application (dashboards, data sets, data models, automated schedules).
Any other metadata that may be added from time to time.
"Europe" means the European Union, the European Economic Area and/or their member states, Switzerland, and the United Kingdom.
"European Data" means Personal Data that is subject to the protection of European Data Protection Laws.
"European Data Protection Laws" means data protection laws applicable in Europe, including:
Regulation 2016/679 of the European Parliament and of the Council (General Data Protection Regulation, "GDPR");
Directive 2002/58/EC concerning the processing of personal data and the protection of privacy in the electronic communications sector;
Applicable national implementations of (i) and (ii);
UK GDPR as it forms part of UK domestic law by virtue of Section 3 of the European Union (Withdrawal) Act 2018;
Swiss Federal Data Protection Act of 19 June 1992 and its Ordinance (“Swiss DPA”), as may be amended, superseded, or replaced.
"Instruction" means the written instruction issued by Customer to Holistics directing it to perform a specific action regarding the Customer Database (e.g., depersonalising, blocking, deletion, making available). Instructions shall initially be specified in the Terms and may be amended or replaced by Customer in separate written instructions.
"PDPA" refers to the Personal Data Protection Act 2012 legislated in Singapore.
"Personal Data" means the personal data contained within the Customer Database, including any special categories of personal data defined under the Data Protection Laws of each jurisdiction, in each case processed by Holistics under the Terms.
"Personal Data Breach" means a breach of security leading to accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, Personal Data transmitted, stored or otherwise processed by Holistics and/or its Sub-Processors in connection with the provision of the Subscription Services. It shall not include unsuccessful attempts or activities that do not compromise the security of Personal Data, such as failed log-in attempts, pings, port scans, denial-of-service attacks, or other network attacks.
"Process" or "Processing" means any operation performed on Personal Data, including collection, recording, organization, structuring, storage, adaptation, retrieval, consultation, use, disclosure, dissemination, alignment, restriction, or erasure.
"SCCs" means the Customer SCCs and/or applicable SCCs, including:
Module 2: Controller in Europe to processor (C2P)
Module 3: Processor in Europe to another processor (P2P)
UK SCC: Controller in the UK to processor
"Sub-Processor" means any Processor engaged by Holistics or its Affiliates to assist in fulfilling the obligations with respect to the Subscription Services under the Agreement. Sub-Processors may include third parties or Affiliates, excluding Holistics employees or consultants.
"Temporary Cached Query Results" refer to all results provided to Customer, End Users, or via APIs for queries executed against the Customer Database via Holistics. These are cached temporarily and expire automatically after a specific time (minimum 10 minutes) post-query execution.
"Terms" refers to the Terms of Service at https://www.holistics.io/terms.
### Introduction
This Data Processing Agreement ("DPA") reflects the parties' agreement with respect to the terms governing the Processing of data in the Customer Database under the Holistics Customer Terms of Service ("Terms"), and supersedes any previously signed DPA on an earlier date.
The DPA is an addon to, and forms an integral part of the Terms. It is effective upon its incorporation into the Terms, an online self-service purchase, or an Order or an executed amendment to the Agreement.
The terms "personal data", "data subject", "processing", "controller" and "processor" used in this DPA have the meanings given in the GDPR irrespective of whether European Data Protection Law or Non-European Data Protection Law applies.
The terms "Personal Data", "Customer Data", and "Customer Database" may be used interchangeably in this DPA.
This DPA shall follow the term of the Terms, including but not restricted to the Terms clauses
"Account Information from Third Party Providers"
"Limitation of Liability" and
"Indemnification" clauses.
In case of any conflict or inconsistency with the Terms, this DPA will take precedence to the extent of such conflict or inconsistency
The duration of Processing shall be the same as the duration of the Terms and this DPA.
The clauses of this DPA shall follow the Terms. Definitions not otherwise defined above herein shall have the meaning as set forth in the Terms.
### Holistics' Responsibilities
Holistics will only Process Customer Database for the purposes described in this DPA or as otherwise agreed within the scope of the Customer's Instructions, except where and to the extent otherwise required by applicable law.
Holistics will only access or use Customer Database to provide the Services ordered by Customer and will not use it for any other Holistics products, services, advertising, or to resell the data.
Holistics is not responsible for compliance with any Data Protection Laws applicable to the Customer's industry that are not applicable to us.
Holistics shall email the customer if we become aware of a confirmed breach and also further
Take any such reasonably necessary measures and actions to remedy or mitigate the effects of the Breach and
Keep the Customer informed of all material developments in connection with the Breach.
Provide reasonable information and cooperation so that the Customer can fulfill any data breach reporting obligations it may have under (and in accordance with the timescales required by) the applicable Data Protection law.
If any such request, correspondence, enquiry or complaint is made directly to the Holistics, Holistics will promptly inform the Customer providing full details of the same.
Holistics will take the appropriate technical and organisational measures (listed in Annex 2) to adequately protect Customer Database against misuse and loss in accordance with the requirements of the applicable national data protection law. Such measures hereunder shall include, but not be limited to,
the prevention of unauthorised persons from gaining access to Customer Database (physical access control),
the prevention of Customer Database from being accessed without authorisation (logical access control),
ensuring that Customer Database cannot be read, copied, modified or deleted without authorisation during electronic transmission and Holistics Software instance. (data transfer control),
Have a reasonable audit trail system in place to document whether and by whom information on Customer Database has been entered into, modified in, or removed from Customer Database (entry control),
ensuring that data from Customer Database are processed solely in accordance with the Instructions (control of instructions),
persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality,
Holistics Data Protection Team will provide prompt and reasonable assistance with any Customer queries related to processing of Customer Personal Data under the Agreement and can be contacted at [redacted].
### Customer Responsibilities
Customer is responsible for complying with all applicable Data Protection Laws with respect to its Processing of Personal Data in the Customer Database connected to Holistics.
Customer shall retain title to their Customer Database connected to the Holistics Software instance and take technical safeguards to provision (and not over-provision) the appropriate level of data source connection for the user credentials supplied to Holistics.
Customer shall be solely responsible for
the accuracy, quality, and legality of Customer Database and the means in which Personal Data is acquired;
complying with all necessary transparency and lawfulness requirements under applicable Data Protection Laws for the collection and use of the Personal Data, including obtaining any necessary consents and authorizations (particularly for use by Customer for marketing purposes);
complying with the statutory requirements relating to data protection, in particular regarding safeguards against unauthorized access to Customer Database from Holistics software systems.
Customer shall inform Holistics without undue delay and comprehensively about any errors or irregularities related to statutory provisions on the Processing of Customer Database detected during a verification of the results of such Processing.
Customer is responsible for security relating to its environment and databases and security relating its configuration of the Software. This includes implementing and managing procedural, technical, and administrative safeguards on its software and networks sufficient to:
ensure the confidentiality, security, integrity, and privacy of Customer Database in transit, at rest, and in storage;
protect against any anticipated threats or hazards to the security and integrity of Customer Database; and
protect against any unauthorized processing, loss, use, disclosure or acquisition of or access to Customer Database.
Customer will minimize the sharing of Personal Data of Data Subjects in the support tickets and emails information sent to Holistics.
If such Personal Data needs to be included for troubleshooting, the Customer will deliberately add specific Instructions to handle such email communications.
For the avoidance of doubt, emails sent by the Customer with generic company email content confidentiality boilerplates appended by default will not be classified as confidential information.
Notwithstanding any other provision of this DPA, the Terms or any other agreement related to the Software and Services, Holistics has no obligations or liability as to any breach or loss resulting from:
The Customer's environment, databases, systems or software, or
The Customer's security configuration or administration of the Software.
Customer is solely responsible for provisioning Users on the Software, including:
methods of authenticating Users (such as industry-standard secure username/password policies, two-factor authentication etc);
Restricting access by User or group, and from the database level down to the row or column level;
Managing admin privileges;
deauthorizing personnel who no longer need access to the Software;
setting up any API usage in a secure way; and
regularly auditing any public access links Users create and restricting the permission to create public links, as necessary.
Customer is responsible to remove the network connection between Customer Database and the Holistics Software Instance should they terminate the Subscription Service.
### Customer Database Sub-Processors
Customer consents to Holistics engaging affiliates and third party sub-processors to process data in Customer Database for the purpose as described in the Terms.
Holistics will maintain an up-to-date list of its sub-processors. For avoidance of doubt, the above consent constitutes Customer's prior written consent to the sub-Processing by Holistics (Annex 3)
Holistics will impose data protection terms on any sub-processor it appoints as required to protect Customer Data to the standard required by the Data Protection Laws.
If Holistics intends to instruct sub-Processors other than the companies listed in Annex 3, Holistics will notify the Customer thereof in writing (email to the email address(es) on record in Processor's account information for Customer is sufficient) and will give the Customer the opportunity to object to the engagement of the new sub-Processors within 30 days after being notified.
The objection, if raised, must be based on reasonable grounds (e.g. if the Customer proves that significant risks for the protection of its Customer Data exist at the sub-Processor).
In such an event, Holistics will either not appoint or replace the sub-processor or, if this is not possible, Customer may suspend or terminate the Terms (without prejudice to any fees incurred by Customer prior to suspension or termination).
**Data Transfers**
Customer acknowledges and agrees that Holistics may access and process Customer Data on a global basis as necessary to provide the Subscription Service in accordance with the Agreement, and in particular that Customer Data may be transferred to the data centre location(s) that Holistics operates in.
Holistics may store and process (i) Holistics Metadata and Usage Data and (ii) Temporary Cached Query Results anywhere Holistics or its Sub-processors maintain facilities, subject to Sections on Additional Provisions for European Data, Additional Provisions for California Personal Information, or other jurisdictions where Holistics operates in.
The physical data records residing in Customer Database will not be stored permanently by Holistics application servers outside of the purpose set in the Terms.
Temporary Cached Query Results needed to visualize the dashboard data will be temporarily stored in Holistics, and will automatically expire after a specific time duration.
Wherever Personal Data is transferred outside its country of origin, each party will ensure such transfers are made in compliance with the requirements of Data Protection Laws.
### Provisions Specific for European Data
The parties acknowledge and agree that European Data Protection Law will apply to the processing of Customer Data if:
The processing is carried out in the context of the activities of an establishment of Customer in the territory of the EEA or the UK; and/or
Customer Personal Data relates to data subjects who are in the EEA or the UK, and the processing concerns offering them goods/services or monitoring their behavior in those regions.
Definitions:
"Controller" means the natural or legal person, public authority, agency or other body which, alone or jointly with others, determines the purposes and means of the processing of Personal Data.
"Processor" means a natural or legal person, public authority, agency or other body which processes Personal Data on behalf of the Controller.
Relationship between Customer and Holistics:
Holistics is the Processor of the Customer Database for the purposes described in the Terms.
If Customer:
is the Controller of data (which may include Personal Data and Data Subjects) stored in the Customer Database, then SCC Module 2 applies (Annex 4A - Controller to Processor);
is the Processor of data stored in the Customer Database, then SCC Module 3 applies (Annex 4B - Processor to Processor).
Holistics and the Customer are separately responsible for compliance with applicable data protection regulations.
Legacy MCCs: The SCCs will, as of the Transition Date, supersede and terminate any Model Contract Clauses approved under Directive 95/46/EC and previously entered into by Customer and Holistics.
The Transition Date means:
October 27, 2021, if (a) Customer’s billing address is outside EMEA, and (b) the processing of Customer Personal Data is subject to European Data Protection Law.
Otherwise, September 27, 2021.
Data Protection Impact Assessments and Consultation with Supervisory Authorities:
Providing and updating public documentation on technical security measures (see: /docs/data-security);
Providing documentation on caching and job queuing mechanisms (see: /docs/performance/data-caching);
Providing the Security Measures (Annex 2) as part of the Agreement;
Upon request, offering additional reasonable cooperation where above measures are insufficient.
Transfer Mechanism for Data Transfers:
Permitted Transfers: No SCCs or alternative mechanisms required if processing is in or to an Adequate Country.
Restricted Transfers:
The EU SCCs apply to transfers subject to the EU GDPR and/or Swiss FDPA;
The UK SCCs apply to transfers subject to the UK GDPR;
Holistics agrees to process European Data in compliance with the SCCs;
Holistics will inform Customer of any conflict with the Singapore PDPA.
For purposes of SCCs:
Holistics = "data importer", Customer = "data exporter";
The Annexes will be populated as per Annex 1 and 2 of this DPA;
SCCs prevail in case of conflict with this DPA.
If SCCs are not legally valid under GDPR, fallback standard clauses under GDPR shall be incorporated by reference, and Annexes 1 and 2 used accordingly.
Demonstration of Compliance:
Holistics will provide information and allow audits as required to demonstrate DPA compliance.
Customer agrees to audit via the process described in this section and Clause 8 of SCCs.
Customer acknowledges that the service is hosted by certified data center partners (see sub-processors list).
Holistics may charge a reasonable fee for audits, with cost details disclosed in advance.
Holistics may object to an auditor if not suitably qualified, independent, or is a competitor.
Processing Records: Holistics will maintain documentation of its processing activities. Where GDPR requires Holistics to hold Customer information, Customer will provide and maintain accurate information. This may be disclosed to Supervisory Authorities.
No Modification of SCCs: Nothing in these Terms shall modify or contradict the SCCs or affect the fundamental rights of data subjects under European Data Protection Law.
### Provisions Specific for California Personal Information
This section will apply only with respect to California Personal Information residing in Customer Database.
When processing California Personal Information in accordance with Customer's Instructions, the parties acknowledge and agree that Customer is a Business and Holistics is a Service Provider for the purposes of the CCPA.
Both parties agree that Holistics will Process California Personal Information as a Service Provider strictly for the purpose of performing the Subscription Services or as otherwise permitted by the CCPA, including as described in our Terms.
### Limitation of Liability
Each party's liability, taken together in the aggregate, arising out of or related to this DPA, and all DPAs between Customer and Holistics, whether in contract, tort or under any other theory of liability, is subject to the 'Limitation of Liability' section of the Terms, and any reference in such section to the liability of a party means the aggregate liability of that party under the Agreement and all DPAs together.
For the avoidance of doubt, Holistics' total liability for all claims from the Customer arising out of or related to the Agreement and each DPA shall apply in the aggregate for all claims under both the Agreement and all DPAs established under the Agreement by the Customer.
### Governing Law and Disputes
This DPA will be governed by and construed in accordance with the laws of the Singapore, unless otherwise required by
EU Data Protection Law, in which case this DPA will be governed by the laws of the Member State in which the Customer is established.
CCPA, in which case this DPA will be governed by the laws of California, USA.
the Data Protection Laws of each jurisdiction the Customer operates in
If Holistics becomes aware that Customer Data cannot be processed in accordance with the Customer's Instructions due to a legal requirement under any applicable law, Holistics will
promptly notify Customer that legal requirement to the extent permitted by the applicable law; and
where necessary, cease all Processing (other than merely storing and maintaining the security of the affected Customer Data) until such time as the Customer issues new Instructions with which Holistics is able to comply. If this provision is invoked, Holistics will not be liable to the Customer under the Agreement for any failure to perform the applicable Subscription Services until such time as Customer issues new lawful Instructions with regard to the Processing.
Arb-Med-Arb: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore.
The Tribunal shall consist of one (1) arbitrator(s)
The language of the arbitration shall be English
**Included Core Documents** :
- This Data Protection Agreement (DPA), as defined in https://go.holistics.io/dpa
- Holistics Terms of Service (Terms), as defined in [https://holistics.io/terms](https://holistics.io/terms)
- Annex 1: Subject Matter and Details of Data Processing
- Annex 2: Security Measures (Technical And Organisational Measures To Ensure The Security Of The Data)
- Annex 3: List of Holistics Sub-Processors
Selective Annex(es) where applicable to Customer
- Annex 4A: EU SCC Module 2 (Controller to Processor)
- Annex 4B: EU SCC Module 3 (Processor to Processor)
- Annex 5: UK SCC (Controller to Processor)
| Signature: | Signature: |
| -------------------------- | -------------- |
| Name: [redacted] | Customer Name: |
| Designation: [redacted] | Designation: |
| Holistics Software Pte Ltd | Company: |
| Date: | Date: |
## Annex 1: Subject Matter and Details of Data Processing
Please refer to [Annex 1: Subject Matter and Details of Data Processing](/legal/archive/annex-subject-matter/2026-06-11).
## Annex 2: Security Measures
Please refer to [Annex 2: Security Measures](/legal/archive/annex-security-measures/2026-06-11).
## Annex 3: List of Holistics Sub-Processors
Please refer to [Annex 3: List of Holistics Sub-Processors](/legal/archive/annex-sub-processors/2026-06-11).
## Annex 4A: EU SCC Module 2 (Controller to Processor)
**Note:** If there are actual differences between the official EU SCC
Module and this version below, the official EU SCC Module prevails.
---
STANDARD CONTRACTUAL CLAUSES
Controller to Processor
**SECTION I**
**_Clause 1_**
**Purpose and scope**
\(a\) The purpose of these standard contractual clauses is to ensure
compliance with the requirements of Regulation (EU) 2016/679 of the
European Parliament and of the Council of 27 April 2016 on the
protection of natural persons with regard to the processing of
personal data and on the free movement of such data (General Data
Protection Regulation) ([^1]) for the transfer of data to a third
country.
\(b\) The Parties:
\(i\) the natural or legal person(s), public authority/ies, agency/ies
or other body/ies (hereinafter 'entity/ies') transferring the personal
data, as listed in Annex I.A (hereinafter each 'data exporter'), and
\(ii\) the entity/ies in a third country receiving the personal data
from the data exporter, directly or indirectly via another entity also
Party to these Clauses, as listed in Annex I.A (hereinafter each 'data
importer')
have agreed to these standard contractual clauses (hereinafter:
'Clauses').
\(c\) These Clauses apply with respect to the transfer of personal
data as specified in Annex I.B.
\(d\) The Appendix to these Clauses containing the Annexes referred to
therein forms an integral part of these Clauses.
**_Clause 2_**
**Effect and invariability of the Clauses**
\(a\) These Clauses set out appropriate safeguards, including
enforceable data subject rights and effective legal remedies, pursuant
to Article 46(1) and Article 46(2)(c) of Regulation (EU) 2016/679 and,
with respect to data transfers from controllers to processors and/or
processors to processors, standard contractual clauses pursuant to
Article 28(7) of Regulation (EU) 2016/679, provided they are not
modified, except to select the appropriate Module(s) or to add or
update information in the Appendix. This does not prevent the Parties
from including the standard contractual clauses laid down in these
Clauses in a wider contract and/or to add other clauses or additional
safeguards, provided that they do not contradict, directly or
indirectly, these Clauses or prejudice the fundamental rights or
freedoms of data subjects.
\(b\) These Clauses are without prejudice to obligations to which the
data exporter is subject by virtue of Regulation (EU) 2016/679.
**_Clause 3_**
**Third-party beneficiaries**
\(a\) Data subjects may invoke and enforce these Clauses, as
third-party beneficiaries, against the data exporter and/or data
importer, with the following exceptions:
\(i\) Clause 1, Clause 2, Clause 3, Clause 6, Clause 7;
\(ii\) Clause 8.1(b), 8.9(a), (c), (d) and (e);
\(iii\) Clause 9(a), (c), (d) and (e);
\(iv\) Clause 12(a), (d) and (f);
\(v\) Clause 13;
\(vi\) Clause 15.1(c), (d) and (e);
\(vii\) Clause 16(e);
\(viii\) Clause 18(a) and (b).
\(b\) Paragraph (a) is without prejudice to rights of data subjects
under Regulation (EU) 2016/679.
**_Clause 4_**
**Interpretation**
\(a\) Where these Clauses use terms that are defined in Regulation
(EU) 2016/679, those terms shall have the same meaning as in that
Regulation.
\(b\) These Clauses shall be read and interpreted in the light of the
provisions of Regulation (EU) 2016/679.
\(c\) These Clauses shall not be interpreted in a way that conflicts
with rights and obligations provided for in Regulation (EU) 2016/679.
**_Clause 5_**
**Hierarchy**
In the event of a contradiction between these Clauses and the provisions
of related agreements between the Parties, existing at the time these
Clauses are agreed or entered into thereafter, these Clauses shall
prevail.
**_Clause 6_**
**Description of the transfer(s)**
The details of the transfer(s), and in particular the categories of
personal data that are transferred and the purpose(s) for which they are
transferred, are specified in Annex I.B.
**_Clause 7 -- Optional_**
**Docking clause**
\(a\) An entity that is not a Party to these Clauses may, with the
agreement of the Parties, accede to these Clauses at any time, either
as a data exporter or as a data importer, by completing the Appendix
and signing Annex I.A.
\(b\) Once it has completed the Appendix and signed Annex I.A, the
acceding entity shall become a Party to these Clauses and have the
rights and obligations of a data exporter or data importer in> accordance with its designation in Annex I.A.
\(c\) The acceding entity shall have no rights or obligations arising
under these Clauses from the period prior to becoming a Party.
**SECTION II -- OBLIGATIONS OF THE PARTIES**
**_Clause 8_**
**Data protection safeguards**
The data exporter warrants that it has used reasonable efforts to
determine that the data importer is able, through the implementation of
appropriate technical and organisational measures, to satisfy its
obligations under these Clauses.
**8.1 Instructions**
\(a\) The data importer shall process the personal data only on
documented instructions from the data exporter. The data exporter may
give such instructions throughout the duration of the contract.
\(b\) The data importer shall immediately inform the data exporter if
it is unable to follow those instructions.
**8.2 Purpose limitation**
The data importer shall process the personal data only for the specific
purpose(s) of the transfer, as set out in Annex I.B, unless on further
instructions from the data exporter.
**8.3 Transparency**
On request, the data exporter shall make a copy of these Clauses,
including the Appendix as completed by the Parties, available to the
data subject free of charge. To the extent necessary to protect business
secrets or other confidential information, including the measures
described in Annex II and personal data, the data exporter may redact
part of the text of the Appendix to these Clauses prior to sharing a
copy, but shall provide a meaningful summary where the data subject
would otherwise not be able to understand the its content or exercise
his/her rights. On request, the Parties shall provide the data subject
with the reasons for the redactions, to the extent possible without
revealing the redacted information. This Clause is without prejudice to
the obligations of the data exporter under Articles 13 and 14 of
Regulation (EU) 2016/679.
**8.4 Accuracy**
If the data importer becomes aware that the personal data it has
received is inaccurate, or has become outdated, it shall inform the data
exporter without undue delay. In this case, the data importer shall
cooperate with the data exporter to erase or rectify the data.
**8.5 Duration of processing and erasure or return of data**
Processing by the data importer shall only take place for the duration
specified in Annex I.B. After the end of the provision of the processing
services, the data importer shall, at the choice of the data exporter,
delete all personal data processed on behalf of the data exporter and
certify to the data exporter that it has done so, or return to the data
exporter all personal data processed on its behalf and delete existing
copies. Until the data is deleted or returned, the data importer shall
continue to ensure compliance with these Clauses. In case of local laws
applicable to the data importer that prohibit return or deletion of the
personal data, the data importer warrants that it will continue to
ensure compliance with these Clauses and will only process it to the
extent and for as long as required under that local law. This is without
prejudice to Clause 14, in particular the requirement for the data
importer under Clause 14(e) to notify the data exporter throughout the
duration of the contract if it has reason to believe that it is or has
become subject to laws or practices not in line with the requirements
under Clause 14(a).
**8.6 Security of processing**
\(a\) The data importer and, during transmission, also the data
exporter shall implement appropriate technical and organisational
easures to ensure the security of the data, including protection
gainst a breach of security leading to accidental or unlawful
estruction, loss, alteration, unauthorised disclosure or access to
hat data (hereinafter 'personal data breach'). In assessing the
ppropriate level of security, the Parties shall take due account of
he state of the art, the costs of implementation, the nature, scope,
ontext and purpose(s) of processing and the risks involved in the
rocessing for the data subjects. The Parties shall in particular
onsider having recourse to encryption or pseudonymisation, including
uring transmission, where the purpose of processing can be fulfilled
n that manner. In case of pseudonymisation, the additional
nformation for attributing the personal data to a specific data
ubject shall, where possible, remain under the exclusive control of
he data exporter. In complying with its obligations under this
aragraph, the data importer shall at least implement the technical
nd organisational measures specified in Annex II. The data importer
hall carry out regular checks to ensure that these measures continue
o provide an appropriate level of security.
(b\) The data importer shall grant access to the personal data to
embers of its personnel only to the extent strictly necessary for the
mplementation, management and monitoring of the contract. It shall
nsure that persons authorised to process the personal data have
ommitted themselves to confidentiality or are under an appropriate
tatutory obligation of confidentiality.
(c\) In the event of a personal data breach concerning personal data
rocessed by the data importer under these Clauses, the data importer
hall take appropriate measures to address the breach, including
easures to mitigate its adverse effects. The data importer shall also
otify the data exporter without undue delay after having become aware
f the breach. Such notification shall contain the details of a
ontact point where more information can be obtained, a description of
he nature of the breach (including, where possible, categories and
pproximate number of data subjects and personal data records
oncerned), its likely consequences and the measures taken or proposed
o address the breach including, where appropriate, measures to
itigate its possible adverse effects. Where, and in so far as, it is
ot possible to provide all information at the same time, the initial
otification shall contain the information then available and further
nformation shall, as it becomes available, subsequently be provided
ithout undue delay.
(d\) The data importer shall cooperate with and assist the data
xporter to enable the data exporter to comply with its obligations
nder Regulation (EU) 2016/679, in particular to notify the competent
upervisory authority and the affected data subjects, taking into
ccount the nature of processing and the information available to the
data importer.
**8.7 Sensitive data**
Where the transfer involves personal data revealing racial or ethnic
origin, political opinions, religious or philosophical beliefs, or trade
union membership, genetic data, or biometric data for the purpose of
uniquely identifying a natural person, data concerning health or a
person's sex life or sexual orientation, or data relating to criminal
convictions and offences (hereinafter 'sensitive data'), the data
importer shall apply the specific restrictions and/or additional
safeguards described in Annex I.B.
**8.8 Onward transfers**
The data importer shall only disclose the personal data to a third party
on documented instructions from the data exporter. In addition, the data
may only be disclosed to a third party located outside the European
Union ([^2]) (in the same country as the data importer or in another
third country, hereinafter 'onward transfer') if the third party is or
agrees to be bound by these Clauses, under the appropriate Module, or
if:
\(i\) the onward transfer is to a country benefitting from an adequacy
decision pursuant to Article 45 of Regulation (EU) 2016/679 that
covers the onward transfer;
\(ii\) the third party otherwise ensures appropriate safeguards
pursuant to Articles 46 or 47 Regulation of (EU) 2016/679 with respect
to the processing in question;
\(iii\) the onward transfer is necessary for the establishment,
exercise or defence of legal claims in the context of specific
administrative, regulatory or judicial proceedings; or
\(iv\) the onward transfer is necessary in order to protect the vital
interests of the data subject or of another natural person.
Any onward transfer is subject to compliance by the data importer with
all the other safeguards under these Clauses, in particular purpose
limitation.
**8.9 Documentation and compliance**
\(a\) The data importer shall promptly and adequately deal with
enquiries from the data exporter that relate to the processing under
these Clauses.
\(b\) The Parties shall be able to demonstrate compliance with these
Clauses. In particular, the data importer shall keep appropriate
documentation on the processing activities carried out on behalf of
the data exporter.
\(c\) The data importer shall make available to the data exporter all
information necessary to demonstrate compliance with the obligations
set out in these Clauses and at the data exporter's request, allow for
and contribute to audits of the processing activities covered by these
Clauses, at reasonable intervals or if there are indications of
non-compliance. In deciding on a review or audit, the data exporter
may take into account relevant certifications held by the data
importer.
\(d\) The data exporter may choose to conduct the audit by itself or
mandate an independent auditor. Audits may include inspections at the
premises or physical facilities of the data importer and shall, where
appropriate, be carried out with reasonable notice.
\(e\) The Parties shall make the information referred to in paragraphs
(b) and (c), including the results of any audits, available to the
competent supervisory authority on request.
**_Clause 9_**
**Use of sub-processors**
\(a\) OPTION 1: SPECIFIC PRIOR AUTHORISATION The data importer shall
not sub-contract any of its processing activities performed on behalf
of the data exporter under these Clauses to a sub-processor without
the data exporter's prior specific written authorisation. The data
importer shall submit the request for specific authorisation at least
\[_Specify time period_\] prior to the engagement of the
sub-processor, together with the information necessary to enable the
data exporter to decide on the authorisation. The list of
sub-processors already authorised by the data exporter can be found in
Annex III. The Parties shall keep Annex III up to date.
OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the data
exporter's general authorisation for the engagement of
sub-processor(s) from an agreed list. The data importer shall
specifically inform the data exporter in writing of any intended
changes to that list through the addition or replacement of
sub-processors at least \[_Specify time period_\] in advance, thereby
giving the data exporter sufficient time to be able to object to such
changes prior to the engagement of the sub-processor(s). The data
importer shall provide the data exporter with the information
necessary to enable the data exporter to exercise its right to object.
\(b\) Where the data importer engages a sub-processor to carry out
specific processing activities (on behalf of the data exporter), it
shall do so by way of a written contract that provides for, in
substance, the same data protection obligations as those binding the
data importer under these Clauses, including in terms of third-party
beneficiary rights for data subjects. ([^3]) The Parties agree that,
by complying with this Clause, the data importer fulfils its
obligations under Clause 8.8. The data importer shall ensure that the
sub-processor complies with the obligations to which the data importer
is subject pursuant to these Clauses.
\(c\) The data importer shall provide, at the data exporter's request,
a copy of such a sub-processor agreement and any subsequent amendments
to the data exporter. To the extent necessary to protect business
secrets or other confidential information, including personal data,
the data importer may redact the text of the agreement prior to
sharing a copy.
\(d\) The data importer shall remain fully responsible to the data
exporter for the performance of the sub-processor's obligations under
its contract with the data importer. The data importer shall notify
the data exporter of any failure by the sub-processor to fulfil its
obligations under that contract.
\(e\) The data importer shall agree a third-party beneficiary clause
with the sub-processor whereby -- in the event the data importer has
factually disappeared, ceased to exist in law or has become insolvent
-- the data exporter shall have the right to terminate the
sub-processor contract and to instruct the sub-processor to erase or
return the personal data.
**_Clause 10_**
**Data subject rights**
\(a\) The data importer shall promptly notify the data exporter of any
request it has received from a data subject. It shall not respond to
that request itself unless it has been authorised to do so by the data
exporter.
\(b\) The data importer shall assist the data exporter in fulfilling
its obligations to respond to data subjects' requests for the exercise
of their rights under Regulation (EU) 2016/679. In this regard, the
Parties shall set out in Annex II the appropriate technical and
organisational measures, taking into account the nature of the
processing, by which the assistance shall be provided, as well as the
scope and the extent of the assistance required.
\(c\) In fulfilling its obligations under paragraphs (a) and (b), the
data importer shall comply with the instructions from the data
exporter.
**_Clause 11_**
**Redress**
\(a\) The data importer shall inform data subjects in a transparent
and easily accessible format, through individual notice or on its
website, of a contact point authorised to handle complaints. It shall
deal promptly with any complaints it receives from a data subject.
\[OPTION: The data importer agrees that data subjects may also lodge a
complaint with an independent dispute resolution body ([^4]) at no
cost to the data subject. It shall inform the data subjects, in the
manner set out in paragraph (a), of such redress mechanism and that
they are not required to use it, or follow a particular sequence in
seeking redress.\]
\(b\) In case of a dispute between a data subject and one of the
Parties as regards compliance with these Clauses, that Party shall use
its best efforts to resolve the issue amicably in a timely fashion.
The Parties shall keep each other informed about such disputes and,
where appropriate, cooperate in resolving them.
\(c\) Where the data subject invokes a third-party beneficiary right
pursuant to Clause 3, the data importer shall accept the decision of
the data subject to:
\(i\) lodge a complaint with the supervisory authority in the Member
State of his/her habitual residence or place of work, or the competent
supervisory authority pursuant to Clause 13;
\(ii\) refer the dispute to the competent courts within the meaning of
Clause 18.
\(d\) The Parties accept that the data subject may be represented by a
not-for-profit body, organisation or association under the conditions
set out in Article 80(1) of Regulation (EU) 2016/679.
\(e\) The data importer shall abide by a decision that is binding
under the applicable EU or Member State law.
\(f\) The data importer agrees that the choice made by the data
subject will not prejudice his/her substantive and procedural rights
to seek remedies in accordance with applicable laws.
**_Clause 12_**
**Liability**
\(a\) Each Party shall be liable to the other Party/ies for any
damages it causes the other Party/ies by any breach of these Clauses.
\(b\) The data importer shall be liable to the data subject, and the
data subject shall be entitled to receive compensation, for any
material or non-material damages the data importer or its
sub-processor causes the data subject by breaching the third-party
beneficiary rights under these Clauses.
\(c\) Notwithstanding paragraph (b), the data exporter shall be liable
to the data subject, and the data subject shall be entitled to receive
compensation, for any material or non-material damages the data
exporter or the data importer (or its sub-processor) causes the data
subject by breaching the third-party beneficiary rights under these
Clauses. This is without prejudice to the liability of the data
exporter and, where the data exporter is a processor acting on behalf
of a controller, to the liability of the controller under Regulation
(EU) 2016/679 or Regulation (EU) 2018/1725, as applicable.
\(d\) The Parties agree that if the data exporter is held liable under
paragraph (c) for damages caused by the data importer (or its
sub-processor), it shall be entitled to claim back from the data
importer that part of the compensation corresponding to the data
importer's responsibility for the damage.
\(e\) Where more than one Party is responsible for any damage caused
to the data subject as a result of a breach of these Clauses, all
responsible Parties shall be jointly and severally liable and the data
subject is entitled to bring an action in court against any of these
Parties.
\(f\) The Parties agree that if one Party is held liable under
paragraph (e), it shall be entitled to claim back from the other
Party/ies that part of the compensation corresponding to its/their
responsibility for the damage.
\(g\) The data importer may not invoke the conduct of a sub-processor
to avoid its own liability.
**_Clause 13_**
**Supervision**
(a) \[Where the data exporter is established in an EU Member State:\] > The supervisory authority with responsibility for ensuring > compliance by the data exporter with Regulation (EU) 2016/679 as > regards the data transfer, as indicated in Annex I.C, shall act as > competent supervisory authority.
\[Where the data exporter is not established in an EU Member State,
but falls within the territorial scope of application of Regulation
(EU) 2016/679 in accordance with its Article 3(2) and has appointed a
representative pursuant to Article 27(1) of Regulation (EU)
2016/679:\] The supervisory authority of the Member State in which the
representative within the meaning of Article 27(1) of Regulation (EU)
2016/679 is established, as indicated in Annex I.C, shall act as
competent supervisory authority.
\[Where the data exporter is not established in an EU Member State,
but falls within the territorial scope of application of Regulation
(EU) 2016/679 in accordance with its Article 3(2) without however
having to appoint a representative pursuant to Article 27(2) of
Regulation (EU) 2016/679:\] The supervisory authority of one of the
Member States in which the data subjects whose personal data is
transferred under these Clauses in relation to the offering of goods
or services to them, or whose behaviour is monitored, are located, as
indicated in Annex I.C, shall act as competent supervisory authority.
\(b\) The data importer agrees to submit itself to the jurisdiction of
and cooperate with the competent supervisory authority in any
procedures aimed at ensuring compliance with these Clauses. In
particular, the data importer agrees to respond to enquiries, submit
to audits and comply with the measures adopted by the supervisory
authority, including remedial and compensatory measures. It shall
provide the supervisory authority with written confirmation that the
necessary actions have been taken.
**SECTION III -- LOCAL LAWS AND OBLIGATIONS IN CASE OF ACCESS BY PUBLIC
AUTHORITIES**
**_Clause 14_**
**Local laws and practices affecting compliance with the Clauses**
\(a\) The Parties warrant that they have no reason to believe that the
laws and practices in the third country of destination applicable to
the processing of the personal data by the data importer, including
any requirements to disclose personal data or measures authorising
access by public authorities, prevent the data importer from
fulfilling its obligations under these Clauses. This is based on the
understanding that laws and practices that respect the essence of the
fundamental rights and freedoms and do not exceed what is necessary
and proportionate in a democratic society to safeguard one of the
objectives listed in Article 23(1) of Regulation (EU) 2016/679, are
not in contradiction with these Clauses.
\(b\) The Parties declare that in providing the warranty in paragraph
(a), they have taken due account in particular of the following
elements:
\(i\) the specific circumstances of the transfer, including the length
of the processing chain, the number of actors involved and the
transmission channels used; intended onward transfers; the type of
recipient; the purpose of processing; the categories and format of the
transferred personal data; the economic sector in which the transfer
occurs; the storage location of the data transferred;
\(ii\) the laws and practices of the third country of destination--
including those requiring the disclosure of data to public authorities
or authorising access by such authorities -- relevant in light of the
specific circumstances of the transfer, and the applicable limitations
and safeguards ([^5]);
\(iii\) any relevant contractual, technical or organisational
safeguards put in place to supplement the safeguards under these
Clauses, including measures applied during transmission and to the
processing of the personal data in the country of destination.
\(c\) The data importer warrants that, in carrying out the assessment
under paragraph (b), it has made its best efforts to provide the data
exporter with relevant information and agrees that it will continue to
cooperate with the data exporter in ensuring compliance with these
Clauses.
\(d\) The Parties agree to document the assessment under paragraph (b)
and make it available to the competent supervisory authority on
request.
\(e\) The data importer agrees to notify the data exporter promptly
if, after having agreed to these Clauses and for the duration of the
contract, it has reason to believe that it is or has become subject to
laws or practices not in line with the requirements under paragraph
(a), including following a change in the laws of the third country or
a measure (such as a disclosure request) indicating an application of
such laws in practice that is not in line with the requirements in
paragraph (a).
\(f\) Following a notification pursuant to paragraph (e), or if the
data exporter otherwise has reason to believe that the data importer
can no longer fulfil its obligations under these Clauses, the data
exporter shall promptly identify appropriate measures (e.g. technical
or organisational measures to ensure security and confidentiality) to
be adopted by the data exporter and/or data importer to address the
situation. The data exporter shall suspend the data transfer if it
considers that no appropriate safeguards for such transfer can be
ensured, or if instructed by the competent supervisory authority to do
so. In this case, the data exporter shall be entitled to terminate the
contract, insofar as it concerns the processing of personal data under
these Clauses. If the contract involves more than two Parties, the
data exporter may exercise this right to termination only with respect
to the relevant Party, unless the Parties have agreed otherwise. Where
the contract is terminated pursuant to this Clause, Clause 16(d) and
(e) shall apply.
**_Clause 15_**
**Obligations of the data importer in case of access by public
authorities**
**15.1 Notification**
\(a\) The data importer agrees to notify the data exporter and, where
possible, the data subject promptly (if necessary with the help of the
data exporter) if it:
\(i\) receives a legally binding request from a public authority,
including judicial authorities, under the laws of the country of
destination for the disclosure of personal data transferred pursuant
to these Clauses; such notification shall include information about
the personal data requested, the requesting authority, the legal basis
for the request and the response provided; or
\(ii\) becomes aware of any direct access by public authorities to
personal data transferred pursuant to these Clauses in accordance with
the laws of the country of destination; such notification shall
include all information available to the importer.
\(b\) If the data importer is prohibited from notifying the data
exporter and/or the data subject under the laws of the country of
destination, the data importer agrees to use its best efforts to
obtain a waiver of the prohibition, with a view to communicating as
much information as possible, as soon as possible. The data importer
agrees to document its best efforts in order to be able to demonstrate
them on request of the data exporter.
\(c\) Where permissible under the laws of the country of destination,
the data importer agrees to provide the data exporter, at regular
intervals for the duration of the contract, with as much relevant
information as possible on the requests received (in particular,
number of requests, type of data requested, requesting authority/ies,
whether requests have been challenged and the outcome of such
challenges, etc.).
\(d\) The data importer agrees to preserve the information pursuant to
paragraphs (a) to (c) for the duration of the contract and make it
available to the competent supervisory authority on request.
\(e\) Paragraphs (a) to (c) are without prejudice to the obligation of
the data importer pursuant to Clause 14(e) and Clause 16 to inform the
data exporter promptly where it is unable to comply with these
Clauses.
**15.2 Review of legality and data minimisation**
\(a\) The data importer agrees to review the legality of the request
for disclosure, in particular whether it remains within the powers
granted to the requesting public authority, and to challenge the
request if, after careful assessment, it concludes that there are
reasonable grounds to consider that the request is unlawful under the
laws of the country of destination, applicable obligations under
international law and principles of international comity. The data
importer shall, under the same conditions, pursue possibilities of
appeal. When challenging a request, the data importer shall seek
interim measures with a view to suspending the effects of the request
until the competent judicial authority has decided on its merits. It
shall not disclose the personal data requested until required to do so
under the applicable procedural rules. These requirements are without
prejudice to the obligations of the data importer under Clause 14(e).
\(b\) The data importer agrees to document its legal assessment and
any challenge to the request for disclosure and, to the extent
permissible under the laws of the country of destination, make the
documentation available to the data exporter. It shall also make it
available to the competent supervisory authority on request.
\(c\) The data importer agrees to provide the minimum amount of
information permissible when responding to a request for disclosure,
based on a reasonable interpretation of the request.
**SECTION IV -- FINAL PROVISIONS**
**_Clause 16_**
**Non-compliance with the Clauses and termination**
\(a\) The data importer shall promptly inform the data exporter if it
is unable to comply with these Clauses, for whatever reason.
\(b\) In the event that the data importer is in breach of these
Clauses or unable to comply with these Clauses, the data exporter
shall suspend the transfer of personal data to the data importer until
compliance is again ensured or the contract is terminated. This is
without prejudice to Clause 14(f).
\(c\) The data exporter shall be entitled to terminate the contract,
insofar as it concerns the processing of personal data under these
Clauses, where:
\(i\) the data exporter has suspended the transfer of personal data to
the data importer pursuant to paragraph (b) and compliance with these
Clauses is not restored within a reasonable time and in any event
within one month of suspension;
\(ii\) the data importer is in substantial or persistent breach of these
Clauses; or
\(iii\) the data importer fails to comply with a binding decision of a
competent court or supervisory authority regarding its obligations
under these Clauses.
In these cases, it shall inform the competent supervisory authority of
such non-compliance. Where the contract involves more than two
Parties, the data exporter may exercise this right to termination only
with respect to the relevant Party, unless the Parties have agreed
otherwise.
\(d\) Personal data that has been transferred prior to the termination
of the contract pursuant to paragraph (c) shall at the choice of the
data exporter immediately be returned to the data exporter or deleted
in its entirety. The same shall apply to any copies of the data. The
data importer shall certify the deletion of the data to the data
exporter. Until the data is deleted or returned, the data importer
shall continue to ensure compliance with these Clauses. In case of
local laws applicable to the data importer that prohibit the return or
deletion of the transferred personal data, the data importer warrants
that it will continue to ensure compliance with these Clauses and will
only process the data to the extent and for as long as required under
that local law.
\(e\) Either Party may revoke its agreement to be bound by these
Clauses where (i) the European Commission adopts a decision pursuant
to Article 45(3) of Regulation (EU) 2016/679 that covers the transfer
of personal data to which these Clauses apply; or (ii) Regulation (EU)
2016/679 becomes part of the legal framework of the country to which
the personal data is transferred. This is without prejudice to other
obligations applying to the processing in question under Regulation
(EU) 2016/679.
**_Clause 17_**
**Governing law**
These Clauses shall be governed by the law of one of the EU Member
States, provided such law allows for third-party beneficiary rights. The
Parties agree that this shall be the law of \_\_\_\_\_\_\_ (specify
Member State).\]
**_Clause 18_**
**Choice of forum and jurisdiction**
\(a\) Any dispute arising from these Clauses shall be resolved by the
courts of an EU Member State.
\(b\) The Parties agree that those shall be the courts of \_\_\_\_\_
(_specify Member State_).
\(c\) A data subject may also bring legal proceedings against the data
exporter and/or data importer before the courts of the Member State in
which he/she has his/her habitual residence.
\(d\) The Parties agree to submit themselves to the jurisdiction of such
courts.
## Annex 4B: EU SCC Module 3 (Processor to Processor)
**Note:** If there are actual differences between the official EU SCC Module and this version below, the official EU SCC Module prevails.
---
STANDARD CONTRACTUAL CLAUSES
Processor to Processor
**SECTION I**
***Clause 1***
**Purpose and scope**
(a) The purpose of these standard contractual clauses is to ensure compliance with the requirements of Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation) ([^6]) for the transfer of personal data to a third country.
(b) The Parties:
(i) the natural or legal person(s), public authority/ies, agency/ies or other body/ies (hereinafter ‘entity/ies’) transferring the personal data, as listed in Annex I.A (hereinafter each ‘data exporter’), and
(ii) the entity/ies in a third country receiving the personal data from the data exporter, directly or indirectly via another entity also Party to these Clauses, as listed in Annex I.A (hereinafter each ‘data importer’)
have agreed to these standard contractual clauses (hereinafter: ‘Clauses’).
(c) These Clauses apply with respect to the transfer of personal data as specified in Annex I.B.
(d) The Appendix to these Clauses containing the Annexes referred to therein forms an integral part of these Clauses.
***Clause 2***
**Effect and invariability of the Clauses**
(a) These Clauses set out appropriate safeguards, including enforceable data subject rights and effective legal remedies, pursuant to Article 46(1) and Article 46(2)(c) of Regulation (EU) 2016/679 and, with respect to data transfers from controllers to processors and/or processors to processors, standard contractual clauses pursuant to Article 28(7) of Regulation (EU) 2016/679, provided they are not modified, except to select the appropriate Module(s) or to add or update information in the Appendix. This does not prevent the Parties from including the standard contractual clauses laid down in these Clauses in a wider contract and/or to add other clauses or additional safeguards, provided that they do not contradict, directly or indirectly, these Clauses or prejudice the fundamental rights or freedoms of data subjects.
(b) These Clauses are without prejudice to obligations to which the data exporter is subject by virtue of Regulation (EU) 2016/679.
***Clause 3***
**Third-party beneficiaries**
(a) Data subjects may invoke and enforce these Clauses, as third-party beneficiaries, against the data exporter and/or data importer, with the following exceptions:
(i) Clause 1, Clause 2, Clause 3, Clause 6, Clause 7;
(ii) Clause 8.1(a), (c) and (d) and Clause 8.9(a), (c), (d), (e), (f) and (g);
(iii) Clause 9(a), (c), (d) and (e);
(iv) Clause 12(a), (d) and (f);
(v) Clause 13;
(vi) Clause 15.1(c), (d) and (e);
(vii) Clause 16(e);
(viii) Clause 18(a) and (b).
(b) Paragraph (a) is without prejudice to rights of data subjects under Regulation (EU) 2016/679.
***Clause 4***
**Interpretation**
(a) Where these Clauses use terms that are defined in Regulation (EU) 2016/679, those terms shall have the same meaning as in that Regulation.
(b) These Clauses shall be read and interpreted in the light of the provisions of Regulation (EU) 2016/679.
(c) These Clauses shall not be interpreted in a way that conflicts with rights and obligations provided for in Regulation (EU) 2016/679.
***Clause 5***
**Hierarchy**
In the event of a contradiction between these Clauses and the provisions of related agreements between the Parties, existing at the time these Clauses are agreed or entered into thereafter, these Clauses shall prevail.
***Clause 6***
**Description of the transfer(s)**
The details of the transfer(s), and in particular the categories of personal data that are transferred and the purpose(s) for which they are transferred, are specified in Annex I.B.
***Clause 7 – Optional***
**Docking clause**
(a) An entity that is not a Party to these Clauses may, with the agreement of the Parties, accede to these Clauses at any time, either as a data exporter or as a data importer, by completing the Appendix and signing Annex I.A.
(b) Once it has completed the Appendix and signed Annex I.A, the acceding entity shall become a Party to these Clauses and have the rights and obligations of a data exporter or data importer in accordance with its designation in Annex I.A.
(c) The acceding entity shall have no rights or obligations arising under these Clauses from the period prior to becoming a Party.
**SECTION II – OBLIGATIONS OF THE PARTIES**
***Clause 8***
**Data protection safeguards**
The data exporter warrants that it has used reasonable efforts to determine that the data importer is able, through the implementation of appropriate technical and organisational measures, to satisfy its obligations under these Clauses.
**8.1 Instructions**
(a) The data exporter has informed the data importer that it acts as processor under the instructions of its controller(s), which the data exporter shall make available to the data importer prior to processing.
(b) The data importer shall process the personal data only on documented instructions from the controller, as communicated to the data importer by the data exporter, and any additional documented instructions from the data exporter. Such additional instructions shall not conflict with the instructions from the controller. The controller or data exporter may give further documented instructions regarding the data processing throughout the duration of the contract.
(c) The data importer shall immediately inform the data exporter if it is unable to follow those instructions. Where the data importer is unable to follow the instructions from the controller, the data exporter shall immediately notify the controller.
(d) The data exporter warrants that it has imposed the same data protection obligations on the data importer as set out in the contract or other legal act under Union or Member State law between the controller and the data exporter ([^7]).
**8.2 Purpose limitation**
The data importer shall process the personal data only for the specific purpose(s) of the transfer, as set out in Annex I.B., unless on further instructions from the controller, as communicated to the data importer by the data exporter, or from the data exporter.
**8.3 Transparency**
On request, the data exporter shall make a copy of these Clauses, including the Appendix as completed by the Parties, available to the data subject free of charge. To the extent necessary to protect business secrets or other confidential information, including personal data, the data exporter may redact part of the text of the Appendix prior to sharing a copy, but shall provide a meaningful summary where the data subject would otherwise not be able to understand its content or exercise his/her rights. On request, the Parties shall provide the data subject with the reasons for the redactions, to the extent possible without revealing the redacted information.
**8.4 Accuracy**
If the data importer becomes aware that the personal data it has received is inaccurate, or has become outdated, it shall inform the data exporter without undue delay. In this case, the data importer shall cooperate with the data exporter to rectify or erase the data.
**8.5 Duration of processing and erasure or return of data**
Processing by the data importer shall only take place for the duration specified in Annex I.B. After the end of the provision of the processing services, the data importer shall, at the choice of the data exporter, delete all personal data processed on behalf of the controller and certify to the data exporter that it has done so, or return to the data exporter all personal data processed on its behalf and delete existing copies. Until the data is deleted or returned, the data importer shall continue to ensure compliance with these Clauses. In case of local laws applicable to the data importer that prohibit return or deletion of the personal data, the data importer warrants that it will continue to ensure compliance with these Clauses and will only process it to the extent and for as long as required under that local law. This is without prejudice to Clause 14, in particular the requirement for the data importer under Clause 14(e) to notify the data exporter throughout the duration of the contract if it has reason to believe that it is or has become subject to laws or practices not in line with the requirements under Clause 14(a).
**8.6 Security of processing**
(a) The data importer and, during transmission, also the data exporter shall implement appropriate technical and organisational measures to ensure the security of the data, including protection against a breach of security leading to accidental or unlawful destruction, loss, alteration, unauthorised disclosure or access to that data (hereinafter ‘personal data breach’). In assessing the appropriate level of security, they shall take due account of the state of the art, the costs of implementation, the nature, scope, context and purpose(s) of processing and the risks involved in the processing for the data subject. The Parties shall in particular consider having recourse to encryption or pseudonymisation, including during transmission, where the purpose of processing can be fulfilled in that manner. In case of pseudonymisation, the additional information for attributing the personal data to a specific data subject shall, where possible, remain under the exclusive control of the data exporter or the controller. In complying with its obligations under this paragraph, the data importer shall at least implement the technical and organisational measures specified in Annex II. The data importer shall carry out regular checks to ensure that these measures continue to provide an appropriate level of security.
(b) The data importer shall grant access to the data to members of its personnel only to the extent strictly necessary for the implementation, management and monitoring of the contract. It shall ensure that persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality.
(c) In the event of a personal data breach concerning personal data processed by the data importer under these Clauses, the data importer shall take appropriate measures to address the breach, including measures to mitigate its adverse effects. The data importer shall also notify, without undue delay, the data exporter and, where appropriate and feasible, the controller after having become aware of the breach. Such notification shall contain the details of a contact point where more information can be obtained, a description of the nature of the breach (including, where possible, categories and approximate number of data subjects and personal data records concerned), its likely consequences and the measures taken or proposed to address the data breach, including measures to mitigate its possible adverse effects. Where, and in so far as, it is not possible to provide all information at the same time, the initial notification shall contain the information then available and further information shall, as it becomes available, subsequently be provided without undue delay.
(d) The data importer shall cooperate with and assist the data exporter to enable the data exporter to comply with its obligations under Regulation (EU) 2016/679, in particular to notify its controller so that the latter may in turn notify the competent supervisory authority and the affected data subjects, taking into account the nature of processing and the information available to the data importer.
**8.7 Sensitive data**
Where the transfer involves personal data revealing racial or ethnic origin, political opinions, religious or philosophical beliefs, or trade union membership, genetic data, or biometric data for the purpose of uniquely identifying a natural person, data concerning health or a person’s sex life or sexual orientation, or data relating to criminal convictions and offences (hereinafter ‘sensitive data’), the data importer shall apply the specific restrictions and/or additional safeguards set out in Annex I.B.
**8.8 Onward transfers**
The data importer shall only disclose the personal data to a third party on documented instructions from the controller, as communicated to the data importer by the data exporter. In addition, the data may only be disclosed to a third party located outside the European Union ([^8]) (in the same country as the data importer or in another third country, hereinafter ‘onward transfer’) if the third party is or agrees to be bound by these Clauses, under the appropriate Module, or if:
(i) the onward transfer is to a country benefitting from an adequacy decision pursuant to Article 45 of Regulation (EU) 2016/679 that covers the onward transfer;
(ii) the third party otherwise ensures appropriate safeguards pursuant to Articles 46 or 47 of Regulation (EU) 2016/679;
(iii) the onward transfer is necessary for the establishment, exercise or defence of legal claims in the context of specific administrative, regulatory or judicial proceedings; or
(iv) the onward transfer is necessary in order to protect the vital interests of the data subject or of another natural person.
Any onward transfer is subject to compliance by the data importer with all the other safeguards under these Clauses, in particular purpose limitation.
**8.9 Documentation and compliance**
(a) The data importer shall promptly and adequately deal with enquiries from the data exporter or the controller that relate to the processing under these Clauses.
(b) The Parties shall be able to demonstrate compliance with these Clauses. In particular, the data importer shall keep appropriate documentation on the processing activities carried out on behalf of the controller.
(c) The data importer shall make all information necessary to demonstrate compliance with the obligations set out in these Clauses available to the data exporter, which shall provide it to the controller.
(d) The data importer shall allow for and contribute to audits by the data exporter of the processing activities covered by these Clauses, at reasonable intervals or if there are indications of non-compliance. The same shall apply where the data exporter requests an audit on instructions of the controller. In deciding on an audit, the data exporter may take into account relevant certifications held by the data importer.
(e) Where the audit is carried out on the instructions of the controller, the data exporter shall make the results available to the controller.
(f) The data exporter may choose to conduct the audit by itself or mandate an independent auditor. Audits may include inspections at the premises or physical facilities of the data importer and shall, where appropriate, be carried out with reasonable notice.
(g) The Parties shall make the information referred to in paragraphs (b) and (c), including the results of any audits, available to the competent supervisory authority on request.
***Clause 9***
**Use of sub-processors**
(a) OPTION 1: SPECIFIC PRIOR AUTHORISATION The data importer shall not sub-contract any of its processing activities performed on behalf of the data exporter under these Clauses to a sub-processor without the prior specific written authorisation of the controller. The data importer shall submit the request for specific authorisation at least [*Specify time period*] prior to the engagement of the sub-processor, together with the information necessary to enable the controller to decide on the authorisation. It shall inform the data exporter of such engagement. The list of sub-processors already authorised by the controller can be found in Annex III. The Parties shall keep Annex III up to date.
OPTION 2: GENERAL WRITTEN AUTHORISATION The data importer has the controller’s general authorisation for the engagement of sub-processor(s) from an agreed list. The data importer shall specifically inform the controller in writing of any intended changes to that list through the addition or replacement of sub-processors at least [*Specify time period*] in advance, thereby giving the controller sufficient time to be able to object to such changes prior to the engagement of the sub-processor(s). The data importer shall provide the controller with the information necessary to enable the controller to exercise its right to object. The data importer shall inform the data exporter of the engagement of the sub-processor(s).
(b) Where the data importer engages a sub-processor to carry out specific processing activities (on behalf of the controller), it shall do so by way of a written contract that provides for, in substance, the same data protection obligations as those binding the data importer under these Clauses, including in terms of third-party beneficiary rights for data subjects. ([^9]) The Parties agree that, by complying with this Clause, the data importer fulfils its obligations under Clause 8.8. The data importer shall ensure that the sub-processor complies with the obligations to which the data importer is subject pursuant to these Clauses.
(c) The data importer shall provide, at the data exporter’s or controller’s request, a copy of such a sub-processor agreement and any subsequent amendments. To the extent necessary to protect business secrets or other confidential information, including personal data, the data importer may redact the text of the agreement prior to sharing a copy.
(d) The data importer shall remain fully responsible to the data exporter for the performance of the sub-processor’s obligations under its contract with the data importer. The data importer shall notify the data exporter of any failure by the sub-processor to fulfil its obligations under that contract.
(e) The data importer shall agree a third-party beneficiary clause with the sub-processor whereby – in the event the data importer has factually disappeared, ceased to exist in law or has become insolvent – the data exporter shall have the right to terminate the sub-processor contract and to instruct the sub-processor to erase or return the personal data.
***Clause 10***
**Data subject rights**
(a) The data importer shall promptly notify the data exporter and, where appropriate, the controller of any request it has received from a data subject, without responding to that request unless it has been authorised to do so by the controller.
(b) The data importer shall assist, where appropriate in cooperation with the data exporter, the controller in fulfilling its obligations to respond to data subjects’ requests for the exercise of their rights under Regulation (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable. In this regard, the Parties shall set out in Annex II the appropriate technical and organisational measures, taking into account the nature of the processing, by which the assistance shall be provided, as well as the scope and the extent of the assistance required.
(c) In fulfilling its obligations under paragraphs (a) and (b), the data importer shall comply with the instructions from the controller, as communicated by the data exporter.
***Clause 11***
**Redress**
(a) The data importer shall inform data subjects in a transparent and easily accessible format, through individual notice or on its website, of a contact point authorised to handle complaints. It shall deal promptly with any complaints it receives from a data subject.
[OPTION: The data importer agrees that data subjects may also lodge a complaint with an independent dispute resolution body ([^10]) at no cost to the data subject. It shall inform the data subjects, in the manner set out in paragraph (a), of such redress mechanism and that they are not required to use it, or follow a particular sequence in seeking redress.]
` `(b) In case of a dispute between a data subject and one of the Parties as regards compliance with these Clauses, that Party shall use its best efforts to resolve the issue amicably in a timely fashion. The Parties shall keep each other informed about such disputes and, where appropriate, cooperate in resolving them.
(c) Where the data subject invokes a third-party beneficiary right pursuant to Clause 3, the data importer shall accept the decision of the data subject to:
(i) lodge a complaint with the supervisory authority in the Member State of his/her habitual residence or place of work, or the competent supervisory authority pursuant to Clause 13;
(ii) refer the dispute to the competent courts within the meaning of Clause 18.
(d) The Parties accept that the data subject may be represented by a not-for-profit body, organisation or association under the conditions set out in Article 80(1) of Regulation (EU) 2016/679.
(e) The data importer shall abide by a decision that is binding under the applicable EU or Member State law.
(f) The data importer agrees that the choice made by the data subject will not prejudice his/her substantive and procedural rights to seek remedies in accordance with applicable laws.
***Clause 12***
**Liability**
(a) Each Party shall be liable to the other Party/ies for any damages it causes the other Party/ies by any breach of these Clauses.
(b) The data importer shall be liable to the data subject, and the data subject shall be entitled to receive compensation, for any material or non-material damages the data importer or its sub-processor causes the data subject by breaching the third-party beneficiary rights under these Clauses.
(c) Notwithstanding paragraph (b), the data exporter shall be liable to the data subject, and the data subject shall be entitled to receive compensation, for any material or non-material damages the data exporter or the data importer (or its sub-processor) causes the data subject by breaching the third-party beneficiary rights under these Clauses. This is without prejudice to the liability of the data exporter and, where the data exporter is a processor acting on behalf of a controller, to the liability of the controller under Regulation (EU) 2016/679 or Regulation (EU) 2018/1725, as applicable.
(d) The Parties agree that if the data exporter is held liable under paragraph (c) for damages caused by the data importer (or its sub-processor), it shall be entitled to claim back from the data importer that part of the compensation corresponding to the data importer’s responsibility for the damage.
(e) Where more than one Party is responsible for any damage caused to the data subject as a result of a breach of these Clauses, all responsible Parties shall be jointly and severally liable and the data subject is entitled to bring an action in court against any of these Parties.
(f) The Parties agree that if one Party is held liable under paragraph (e), it shall be entitled to claim back from the other Party/ies that part of the compensation corresponding to its/their responsibility for the damage.
(g) The data importer may not invoke the conduct of a sub-processor to avoid its own liability.
***Clause 13***
**Supervision**
1) [Where the data exporter is established in an EU Member State:] The supervisory authority with responsibility for ensuring compliance by the data exporter with Regulation (EU) 2016/679 as regards the data transfer, as indicated in Annex I.C, shall act as competent supervisory authority.
[Where the data exporter is not established in an EU Member State, but falls within the territorial scope of application of Regulation (EU) 2016/679 in accordance with its Article 3(2) and has appointed a representative pursuant to Article 27(1) of Regulation (EU) 2016/679:] The supervisory authority of the Member State in which the representative within the meaning of Article 27(1) of Regulation (EU) 2016/679 is established, as indicated in Annex I.C, shall act as competent supervisory authority.
[Where the data exporter is not established in an EU Member State, but falls within the territorial scope of application of Regulation (EU) 2016/679 in accordance with its Article 3(2) without however having to appoint a representative pursuant to Article 27(2) of Regulation (EU) 2016/679:] The supervisory authority of one of the Member States in which the data subjects whose personal data is transferred under these Clauses in relation to the offering of goods or services to them, or whose behaviour is monitored, are located, as indicated in Annex I.C, shall act as competent supervisory authority.
(b) The data importer agrees to submit itself to the jurisdiction of and cooperate with the competent supervisory authority in any procedures aimed at ensuring compliance with these Clauses. In particular, the data importer agrees to respond to enquiries, submit to audits and comply with the measures adopted by the supervisory authority, including remedial and compensatory measures. It shall provide the supervisory authority with written confirmation that the necessary actions have been taken.
**SECTION III – LOCAL LAWS AND OBLIGATIONS IN CASE OF ACCESS BY PUBLIC AUTHORITIES**
***Clause 14***
**Local laws and practices affecting compliance with the Clauses**
(a) The Parties warrant that they have no reason to believe that the laws and practices in the third country of destination applicable to the processing of the personal data by the data importer, including any requirements to disclose personal data or measures authorising access by public authorities, prevent the data importer from fulfilling its obligations under these Clauses. This is based on the understanding that laws and practices that respect the essence of the fundamental rights and freedoms and do not exceed what is necessary and proportionate in a democratic society to safeguard one of the objectives listed in Article 23(1) of Regulation (EU) 2016/679, are not in contradiction with these Clauses.
(b) The Parties declare that in providing the warranty in paragraph (a), they have taken due account in particular of the following elements:
(i) the specific circumstances of the transfer, including the length of the processing chain, the number of actors involved and the transmission channels used; intended onward transfers; the type of recipient; the purpose of processing; the categories and format of the transferred personal data; the economic sector in which the transfer occurs; the storage location of the data transferred;
(ii) the laws and practices of the third country of destination– including those requiring the disclosure of data to public authorities or authorising access by such authorities – relevant in light of the specific circumstances of the transfer, and the applicable limitations and safeguards ([^11]);
(iii) any relevant contractual, technical or organisational safeguards put in place to supplement the safeguards under these Clauses, including measures applied during transmission and to the processing of the personal data in the country of destination.
(c) The data importer warrants that, in carrying out the assessment under paragraph (b), it has made its best efforts to provide the data exporter with relevant information and agrees that it will continue to cooperate with the data exporter in ensuring compliance with these Clauses.
(d) The Parties agree to document the assessment under paragraph (b) and make it available to the competent supervisory authority on request.
(e) The data importer agrees to notify the data exporter promptly if, after having agreed to these Clauses and for the duration of the contract, it has reason to believe that it is or has become subject to laws or practices not in line with the requirements under paragraph (a), including following a change in the laws of the third country or a measure (such as a disclosure request) indicating an application of such laws in practice that is not in line with the requirements in paragraph (a). The data exporter shall forward the notification to the controller.
(f) Following a notification pursuant to paragraph (e), or if the data exporter otherwise has reason to believe that the data importer can no longer fulfil its obligations under these Clauses, the data exporter shall promptly identify appropriate measures (e.g. technical or organisational measures to ensure security and confidentiality) to be adopted by the data exporter and/or data importer to address the situation, if appropriate in consultation with the controller. The data exporter shall suspend the data transfer if it considers that no appropriate safeguards for such transfer can be ensured, or if instructed by the controller or the competent supervisory authority to do so. In this case, the data exporter shall be entitled to terminate the contract, insofar as it concerns the processing of personal data under these Clauses. If the contract involves more than two Parties, the data exporter may exercise this right to termination only with respect to the relevant Party, unless the Parties have agreed otherwise. Where the contract is terminated pursuant to this Clause, Clause 16(d) and (e) shall apply.
***Clause 15***
**Obligations of the data importer in case of access by public authorities**
**15.1 Notification**
(a) The data importer agrees to notify the data exporter and, where possible, the data subject promptly (if necessary with the help of the data exporter) if it:
(i) receives a legally binding request from a public authority, including judicial authorities, under the laws of the country of destination for the disclosure of personal data transferred pursuant to these Clauses; such notification shall include information about the personal data requested, the requesting authority, the legal basis for the request and the response provided; or
(ii) becomes aware of any direct access by public authorities to personal data transferred pursuant to these Clauses in accordance with the laws of the country of destination; such notification shall include all information available to the importer.
The data exporter shall forward the notification to the controller.
1) If the data importer is prohibited from notifying the data exporter and/or the data subject under the laws of the country of destination, the data importer agrees to use its best efforts to obtain a waiver of the prohibition, with a view to communicating as much information as possible, as soon as possible. The data importer agrees to document its best efforts in order to be able to demonstrate them on request of the data exporter.
1) Where permissible under the laws of the country of destination, the data importer agrees to provide the data exporter, at regular intervals for the duration of the contract, with as much relevant information as possible on the requests received (in particular, number of requests, type of data requested, requesting authority/ies, whether requests have been challenged and the outcome of such challenges, etc.). The data exporter shall forward the information to the controller.
1) The data importer agrees to preserve the information pursuant to paragraphs (a) to (c) for the duration of the contract and make it available to the competent supervisory authority on request.
1) Paragraphs (a) to (c) are without prejudice to the obligation of the data importer pursuant to Clause 14(e) and Clause 16 to inform the data exporter promptly where it is unable to comply with these Clauses.
**15.2 Review of legality and data minimization**
(a) The data importer agrees to review the legality of the request for disclosure, in particular whether it remains within the powers granted to the requesting public authority, and to challenge the request if, after careful assessment, it concludes that there are reasonable grounds to consider that the request is unlawful under the laws of the country of destination, applicable obligations under international law and principles of international comity. The data importer shall, under the same conditions, pursue possibilities of appeal. When challenging a request, the data importer shall seek interim measures with a view to suspending the effects of the request until the competent judicial authority has decided on its merits. It shall not disclose the personal data requested until required to do so under the applicable procedural rules. These requirements are without prejudice to the obligations of the data importer under Clause 14(e).
(b) The data importer agrees to document its legal assessment and any challenge to the request for disclosure and, to the extent permissible under the laws of the country of destination, make the documentation available to the data exporter. It shall also make it available to the competent supervisory authority on request. The data exporter shall make the assessment available to the controller.
(c) The data importer agrees to provide the minimum amount of information permissible when responding to a request for disclosure, based on a reasonable interpretation of the request.
**SECTION IV – FINAL PROVISIONS**
***Clause 16***
**Non-compliance with the Clauses and termination**
(a) The data importer shall promptly inform the data exporter if it is unable to comply with these Clauses, for whatever reason.
(b) In the event that the data importer is in breach of these Clauses or unable to comply with these Clauses, the data exporter shall suspend the transfer of personal data to the data importer until compliance is again ensured or the contract is terminated. This is without prejudice to Clause 14(f).
(c) The data exporter shall be entitled to terminate the contract, insofar as it concerns the processing of personal data under these Clauses, where:
(i) the data exporter has suspended the transfer of personal data to the data importer pursuant to paragraph (b) and compliance with these Clauses is not restored within a reasonable time and in any event within one month of suspension;
(ii) the data importer is in substantial or persistent breach of these Clauses; or
(iii) the data importer fails to comply with a binding decision of a competent court or supervisory authority regarding its obligations under these Clauses.
In these cases, it shall inform the competent supervisory authority and the controller of such non-compliance. Where the contract involves more than two Parties, the data exporter may exercise this right to termination only with respect to the relevant Party, unless the Parties have agreed otherwise.
(d) Personal data that has been transferred prior to the termination of the contract pursuant to paragraph (c) shall at the choice of the data exporter immediately be returned to the data exporter or deleted in its entirety. The same shall apply to any copies of the data. The data importer shall certify the deletion of the data to the data exporter. Until the data is deleted or returned, the data importer shall continue to ensure compliance with these Clauses. In case of local laws applicable to the data importer that prohibit the return or deletion of the transferred personal data, the data importer warrants that it will continue to ensure compliance with these Clauses and will only process the data to the extent and for as long as required under that local law.
(e) Either Party may revoke its agreement to be bound by these Clauses where (i) the European Commission adopts a decision pursuant to Article 45(3) of Regulation (EU) 2016/679 that covers the transfer of personal data to which these Clauses apply; or (ii) Regulation (EU) 2016/679 becomes part of the legal framework of the country to which the personal data is transferred. This is without prejudice to other obligations applying to the processing in question under Regulation (EU) 2016/679.
***Clause 17***
**Governing law**
These Clauses shall be governed by the law of one of the EU Member States, provided such law allows for third-party beneficiary rights. The Parties agree that this shall be the law of \_\_\_\_\_\_\_ (*specify Member State*).]
***Clause 18***
**Choice of forum and jurisdiction**
(a) Any dispute arising from these Clauses shall be resolved by the courts of an EU Member State.
(b) The Parties agree that those shall be the courts of \_\_\_\_\_ (specify Member State).
(c) A data subject may also bring legal proceedings against the data exporter and/or data importer before the courts of the Member State in which he/she has his/her habitual residence.
(d) The Parties agree to submit themselves to the jurisdiction of such courts
## Annex 5: UK SCC (Controller to Processor)
**Note:** If there are actual differences between the official UK SCC Module and this version below, the official EU SCC Module prevails.
---
[CONTROLLER TO PROCESSOR MODEL CLAUSES: SET II]
Commission Decision C(2010)593
Standard Contractual Clauses (processors)
For the purposes of Article 26(2) UK GDPR for the transfer of personal data to processors established in third countries which do not ensure an adequate level of data protection
Name of the data exporting organisation: [Transferor]
(the data **exporter** )
And
Name of the data importing organisation: [Transferee]
(the data **importer** )
each a "party"; together "the parties",
HAVE AGREED on the following Contractual Clauses (the Clauses) in order to adduce adequate safeguards with respect to the protection of privacy and fundamental rights and freedoms of individuals for the transfer by the data exporter to the data importer of the personal data specified in Appendix 1.
_Clause 1_
_ **Definitions** _
For the purposes of the Clauses:
(a)'personal data', 'special categories of data', 'process/processing', 'controller', 'processor', 'data subject' and 'supervisory authority' shall have the same meaning as in the UK GDPR;
(b) 'the data exporter' means the controller who transfers the personal data;
(c) 'the data importer' means the processor who agrees to receive from the data exporter personal data intended for processing on his behalf after the transfer in accordance with his instructions and the terms of the Clauses and who is not subject to a third country's system ensuring adequate protection within the meaning of Article 25(1) UK GDPR;
(d) 'the subprocessor' means any processor engaged by the data importer or by any other subprocessor of the data importer who agrees to receive from the data importer or from any other subprocessor of the data importer personal data exclusively intended for processing activities to be carried out on behalf of the data exporter after the transfer in accordance with his instructions, the terms of the Clauses and the terms of the written subcontract;
(e) 'the applicable data protection law **'** means the legislation protecting the fundamental rights and freedoms of individuals and, in particular, their right to privacy with respect to the processing of personal data applicable to a data controller in the United Kingdom;
(f)'technical and organisational security measures' means those measures aimed at protecting personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing.
_Clause 2_
_ **Details of the transfer** _
The details of the transfer and in particular the special categories of personal data where applicable are specified in Appendix 1 which forms an integral part of the Clauses.
_Clause 3_
_ **Third-party beneficiary clause** _
1. The data subject can enforce against the data exporter this Clause, Clause 4(b) to (i), Clause 5(a) to (e), and (g) to (j), Clause 6(1) and (2), Clause 7, Clause 8(2), and Clauses 9 to 12 as third-party beneficiary.
2. The data subject can enforce against the data importer this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where the data exporter has factually disappeared or has ceased to exist in law unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law, as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity.
3. The data subject can enforce against the subprocessor this Clause, Clause 5(a) to (e) and (g), Clause 6, Clause 7, Clause 8(2), and Clauses 9 to 12, in cases where both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, unless any successor entity has assumed the entire legal obligations of the data exporter by contract or by operation of law as a result of which it takes on the rights and obligations of the data exporter, in which case the data subject can enforce them against such entity. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
4. The parties do not object to a data subject being represented by an association or other body if the data subject so expressly wishes and if permitted by national law.
_Clause 4_
_ **Obligations of the data exporter** _
The data exporter agrees and warrants:
(a) that the processing, including the transfer itself, of the personal data has been and will continue to be carried out in accordance with the relevant provisions of the applicable data protection law (and, where applicable, has been notified to the relevant authorities in the United Kingdom) and does not violate the relevant provisions of the United Kingdom;
(b) that it has instructed and throughout the duration of the personal data processing services will instruct the data importer to process the personal data transferred only on the data exporter's behalf and in accordance with the applicable data protection law and the Clauses;
(c) that the data importer will provide sufficient guarantees in respect of the technical and organisational security measures specified in Appendix 2 to this contract;
(d) that after assessment of the requirements of the applicable data protection law, the security measures are appropriate to protect personal data against accidental or unlawful destruction or accidental loss, alteration, unauthorised disclosure or access, in particular where the processing involves the transmission of data over a network, and against all other unlawful forms of processing, and that these measures ensure a level of security appropriate to the risks presented by the processing and the nature of the data to be protected having regard to the state of the art and the cost of their implementation;
(e) that it will ensure compliance with the security measures;
(f) that, if the transfer involves special categories of data, the data subject has been informed or will be informed before, or as soon as possible after, the transfer that its data could be transmitted to a third country not providing adequate protection within the meaning of the UK GDPR;
(g) to forward any notification received from the data importer or any subprocessor pursuant to Clause 5(b) and Clause 8(3) to the data protection supervisory authority if the data exporter decides to continue the transfer or to lift the suspension;
(h) to make available to the data subjects upon request a copy of the Clauses, with the exception of Appendix 2, and a summary description of the security measures, as well as a copy of any contract for subprocessing services which has to be made in accordance with the Clauses, unless the Clauses or the contract contain commercial information, in which case it may remove such commercial information;
(i) that, in the event of subprocessing, the processing activity is carried out in accordance with Clause 11 by a subprocessor providing at least the same level of protection for the personal data and the rights of data subject as the data importer under the Clauses; and
(j) that it will ensure compliance with Clause 4(a) to (i).
_Clause 5_
_ **Obligations of the data importer [^12]** _
The data importer agrees and warrants:
(a) to process the personal data only on behalf of the data exporter and in compliance with its instructions and the Clauses; if it cannot provide such compliance for whatever reasons, it agrees to inform promptly the data exporter of its inability to comply, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(b) that it has no reason to believe that the legislation applicable to it prevents it from fulfilling the instructions received from the data exporter and its obligations under the contract and that in the event of a change in this legislation which is likely to have a substantial adverse effect on the warranties and obligations provided by the Clauses, it will promptly notify the change to the data exporter as soon as it is aware, in which case the data exporter is entitled to suspend the transfer of data and/or terminate the contract;
(c) that it has implemented the technical and organisational security measures specified in Appendix 2 before processing the personal data transferred;
(d) that it will promptly notify the data exporter about:
(i) any legally binding request for disclosure of the personal data by a law enforcement authority unless otherwise prohibited, such as a prohibition under criminal law to preserve the confidentiality of a law enforcement investigation,
(ii) any accidental or unauthorised access, and
(iii) any request received directly from the data subjects without responding to that request, unless it has been otherwise authorised to do so;
(e) to deal promptly and properly with all inquiries from the data exporter relating to its processing of the personal data subject to the transfer and to abide by the advice of the supervisory authority with regard to the processing of the data transferred;
(f) at the request of the data exporter to submit its data processing facilities for audit of the processing activities covered by the Clauses which shall be carried out by the data exporter or an inspection body composed of independent members and in possession of the required professional qualifications bound by a duty of confidentiality, selected by the data exporter, where applicable, in agreement with the supervisory authority;
(g) to make available to the data subject upon request a copy of the Clauses, or any existing contract for subprocessing, unless the Clauses or contract contain commercial information, in which case it may remove such commercial information, with the exception of Appendix 2 which shall be replaced by a summary description of the security measures in those cases where the data subject is unable to obtain a copy from the data exporter;
(h) that, in the event of subprocessing, it has previously informed the data exporter and obtained its prior written consent;
(i) that the processing services by the subprocessor will be carried out in accordance with Clause 11;
(j) to send promptly a copy of any subprocessor agreement it concludes under the Clauses to the data exporter.
_Clause 6_
_ **Liability** _
1. The parties agree that any data subject, who has suffered damage as a result of any breach of the obligations referred to in Clause 3 or in Clause 11 by any party or subprocessor is entitled to receive compensation from the data exporter for the damage suffered.
2. If a data subject is not able to bring a claim for compensation in accordance with paragraph 1 against the data exporter, arising out of a breach by the data importer or his subprocessor of any of their obligations referred to in Clause 3 or in Clause 11, because the data exporter has factually disappeared or ceased to exist in law or has become insolvent, the data importer agrees that the data subject may issue a claim against the data importer as if it were the data exporter, unless any successor entity has assumed the entire legal obligations of the data exporter by contract of by operation of law, in which case the data subject can enforce its rights against such entity.
The data importer may not rely on a breach by a subprocessor of its obligations in order to avoid its own liabilities.
3. If a data subject is not able to bring a claim against the data exporter or the data importer referred to in paragraphs 1 and 2, arising out of a breach by the subprocessor of any of their obligations referred to in Clause 3 or in Clause 11 because both the data exporter and the data importer have factually disappeared or ceased to exist in law or have become insolvent, the subprocessor agrees that the data subject may issue a claim against the data subprocessor with regard to its own processing operations under the Clauses as if it were the data exporter or the data importer, unless any successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law, in which case the data subject can enforce its rights against such entity. The liability of the subprocessor shall be limited to its own processing operations under the Clauses.
_Clause 7_
_ **Mediation and jurisdiction** _
1. The data importer agrees that if the data subject invokes against it third-party beneficiary rights and/or claims compensation for damages under the Clauses, the data importer will accept the decision of the data subject:
(a) to refer the dispute to mediation, by an independent person or, where applicable, by the supervisory authority;
(b) to refer the dispute to the courts in the United Kingdom.
2. The parties agree that the choice made by the data subject will not prejudice its substantive or procedural rights to seek remedies in accordance with other provisions of national or international law.
_Clause 8_
_ **Cooperation with supervisory authorities** _
1. The data exporter agrees to deposit a copy of this contract with the supervisory authority if it so requests or if such deposit is required under the applicable data protection law.
2. The parties agree that the supervisory authority has the right to conduct an audit of the data importer, and of any subprocessor, which has the same scope and is subject to the same conditions as would apply to an audit of the data exporter under the applicable data protection law.
3. The data importer shall promptly inform the data exporter about the existence of legislation applicable to it or any subprocessor preventing the conduct of an audit of the data importer, or any subprocessor, pursuant to paragraph 2. In such a case the data exporter shall be entitled to take the measures foreseen in Clause 5 (b).
_Clause 9_
_ **Governing Law** _
The Clauses shall be governed by the laws of England and Wales.
_Clause 10_
_ **Variation of the contract** _
The parties undertake not to vary or modify the Clauses. This does not preclude the parties from adding clauses on business related issues where required as long as they do not contradict the Clause.
_Clause 11_
_ **Subprocessing** _
1. The data importer shall not subcontract any of its processing operations performed on behalf of the data exporter under the Clauses without the prior written consent of the data exporter. Where the data importer subcontracts its obligations under the Clauses, with the consent of the data exporter, it shall do so only by way of a written agreement with the subprocessor which imposes the same obligations on the subprocessor as are imposed on the data importer under the Clauses[^13]. Where the subprocessor fails to fulfil its data protection obligations under such written agreement the data importer shall remain fully liable to the data exporter for the performance of the subprocessor's obligations under such agreement.
2. The prior written contract between the data importer and the subprocessor shall also provide for a third-party beneficiary clause as laid down in Clause 3 for cases where the data subject is not able to bring the claim for compensation referred to in paragraph 1 of Clause 6 against the data exporter or the data importer because they have factually disappeared or have ceased to exist in law or have become insolvent and no successor entity has assumed the entire legal obligations of the data exporter or data importer by contract or by operation of law. Such third-party liability of the subprocessor shall be limited to its own processing operations under the Clauses.
3. The provisions relating to data protection aspects for subprocessing of the contract referred to in paragraph 1 shall be governed by the laws of England and Wales.
4. The data exporter shall keep a list of subprocessing agreements concluded under the Clauses and notified by the data importer pursuant to Clause 5 (j), which shall be updated at least once a year. The list shall be available to the data exporter's data protection supervisory authority.
_Clause 12_
_ **Obligation after the termination of personal data processing services** _
1. The parties agree that on the termination of the provision of data processing services, the data importer and the subprocessor shall, at the choice of the data exporter, return all the personal data transferred and the copies thereof to the data exporter or shall destroy all the personal data and certify to the data exporter that it has done so, unless legislation imposed upon the data importer prevents it from returning or destroying all or part of the personal data transferred. In that case, the data importer warrants that it will guarantee the confidentiality of the personal data transferred and will not actively process the personal data transferred anymore.
2. The data importer and the subprocessor warrant that upon request of the data exporter and/or of the supervisory authority, it will submit its data processing facilities for an audit of the measures referred to in paragraph 1.
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 1 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Data exporter**
The data exporter is (please specify briefly your activities relevant to the transfer):
xx
**Data importer**
The data importer is (please specify briefly activities relevant to the transfer):
xx
**Data subjects**
The personal data transferred concern the following categories of data subjects (please specify):
xx
**Categories of data**
The personal data transferred concern the following categories of data (please specify):
xx
**Special categories of data (if appropriate)**
The personal data transferred concern the following special categories of data (please specify):
xx
**Processing operations**
The personal data transferred will be subject to the following basic processing activities (please specify): [_insert_]
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**APPENDIX 2 TO THE STANDARD CONTRACTUAL CLAUSES**
This Appendix forms part of the Clauses and must be completed and signed by the parties.
**Description of the technical and organisational security measures implemented by the data importer in accordance with Clauses 4(d) and 5(c):**
xx
**On behalf of [Transferor]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
**On behalf of [Transferee]:**
Name (written out in full):
Position:
Address:
Other information necessary in order for the contract to be binding (if any):
Signature……………………………………….
(stamp of organisation)
---
[^1]: Where the data exporter is a processor subject to Regulation (EU) 2016/679 acting on behalf of a Union institution or body as controller, reliance on these Clauses when engaging another processor (sub-processing) not subject to Regulation (EU) 2016/679 also ensures compliance with Article 29(4) of Regulation (EU) 2018/1725 of the European Parliament and of the Council of 23 October 2018 on the protection of natural persons with regard to the processing of personal data by the Union institutions, bodies, offices and agencies and on the free movement of such data, and repealing Regulation (EC) No 45/2001 and Decision No 1247/2002/EC ([OJ L 295, 21.11.2018, p. 39](https://eur-lex.europa.eu/legal-content/EN/AUTO/?uri=OJ:L:2018:295:TOC)), to the extent these Clauses and the data protection obligations as set out in the contract or other legal act between the controller and the processor pursuant to Article 29(3) of Regulation (EU) 2018/1725 are aligned. This will in particular be the case where the controller and processor rely on the standard contractual clauses included in Decision 2021/915.
[^2]: The Agreement on the European Economic Area (EEA Agreement) provides for the extension of the European Union's internal market to the three EEA States Iceland, Liechtenstein and Norway. The Union data protection legislation, including Regulation (EU) 2016/679, is covered by the EEA Agreement and has been incorporated into Annex XI thereto. Therefore, any disclosure by the data importer to a third party located in the EEA does not qualify as an onward transfer for the purpose of these Clauses.
[^3]: This requirement may be satisfied by the sub-processor acceding to these Clauses under the appropriate Module, in accordance with Clause 7.
[^4]: The data importer may offer independent dispute resolution through an arbitration body only if it is established in a country that has ratified the New York Convention on Enforcement of Arbitration Awards.
[^5]: As regards the impact of such laws and practices on compliance with these Clauses, different elements may be considered as part of an overall assessment. Such elements may include relevant and documented practical experience with prior instances of requests for disclosure from public authorities, or the absence of such requests, covering a sufficiently representative time-frame. This refers in particular to internal records or other documentation, drawn up on a continuous basis in accordance with due diligence and certified at senior management level, provided that this information can be lawfully shared with third parties. Where this practical experience is relied upon to conclude that the data importer will not be prevented from complying with these Clauses, it needs to be supported by other relevant, objective elements, and it is for the Parties to consider carefully whether these elements together carry sufficient weight, in terms of their reliability and representativeness, to support this conclusion. In particular, the Parties have to take into account whether their practical experience is corroborated and not contradicted by publicly available or otherwise accessible, reliable information on the existence or absence of requests within the same sector and/or the application of the law in practice, such as case law and reports by independent oversight bodies.
[^6]: Where the data exporter is a processor subject to Regulation (EU) 2016/679 acting on behalf of a Union institution or body as controller, reliance on these Clauses when engaging another processor (sub-processing) not subject to Regulation (EU) 2016/679 also ensures compliance with Article 29(4) of Regulation (EU) 2018/1725 of the European Parliament and of the Council of 23 October 2018 on the protection of natural persons with regard to the processing of personal data by the Union institutions, bodies, offices and agencies and on the free movement of such data, and repealing Regulation (EC) No 45/2001 and Decision No 1247/2002/EC (OJ L 295, 21.11.2018, p. 39), to the extent these Clauses and the data protection obligations as set out in the contract or other legal act between the controller and the processor pursuant to Article 29(3) of Regulation (EU) 2018/1725 are aligned. This will in particular be the case where the controller and processor rely on the standard contractual clauses included in Decision 2021/915.
[^7]: See Article 28(4) of Regulation (EU) 2016/679 and, where the controller is an EU institution or body, Article 29(4) of Regulation (EU) 2018/1725.
[^8]: The Agreement on the European Economic Area (EEA Agreement) provides for the extension of the European Union’s internal market to the three EEA States Iceland, Liechtenstein and Norway. The Union data protection legislation, including Regulation (EU) 2016/679, is covered by the EEA Agreement and has been incorporated into Annex XI thereto. Therefore, any disclosure by the data importer to a third party located in the EEA does not qualify as an onward transfer for the purposes of these Clauses.
[^9]: This requirement may be satisfied by the sub-processor acceding to these Clauses under the appropriate Module, in accordance with Clause 7.
[^10]: The data importer may offer independent dispute resolution through an arbitration body only if it is established in a country that has ratified the New York Convention on Enforcement of Arbitration Awards.
[^11]: As regards the impact of such laws and practices on compliance with these Clauses, different elements may be considered as part of an overall assessment. Such elements may include relevant and documented practical experience with prior instances of requests for disclosure from public authorities, or the absence of such requests, covering a sufficiently representative time-frame. This refers in particular to internal records or other documentation, drawn up on a continuous basis in accordance with due diligence and certified at senior management level, provided that this information can be lawfully shared with third parties. Where this practical experience is relied upon to conclude that the data importer will not be prevented from complying with these Clauses, it needs to be supported by other relevant, objective elements, and it is for the Parties to consider carefully whether these elements together carry sufficient weight, in terms of their reliability and representativeness, to support this conclusion. In particular, the Parties have to take into account whether their practical experience is corroborated and not contradicted by publicly available or otherwise accessible, reliable information on the existence or absence of requests within the same sector and/or the application of the law in practice, such as case law and reports by independent oversight bodies.
[^12]: Mandatory requirements of the national legislation applicable to the data importer which do not go beyond what is necessary in a democratic society on the basis of one of the interests listed in Article 13(1) UK GDPR, that is, if they constitute a necessary measure to safeguard national security, defence, public security, the prevention, investigation, detection and prosecution of criminal offences or of breaches of ethics for the regulated professions, an important economic or financial interest of the State or the protection of the data subject or the rights and freedoms of others, are not in contradiction with the standard contractual clauses. Some examples of such mandatory requirements which do not go beyond what is necessary in a democratic society are, inter alia, internationally recognised sanctions, tax-reporting requirements or anti-money-laundering reporting requirements.
[^13]: This requirement may be satisfied by the subprocessor co-signing the contract entered into between the data exporter and the data importer under this Decision.
---
## Data processing agreement (DPA) (effective 28 May 2026)
:::warning Superseded version
This is the **28 May 2026** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Data processing agreement (DPA)](/legal/dpa).
:::
:::tip Where to sign this document
Sign the Holistics Data Processing Agreement at: https://go.holistics.io/signdpa
:::
## Holistics Data Processing Agreement (DPA)
_Last Updated: 28 May 2026_
**Definitions**
"California Personal Information" means Personal Data that is subject to the protection of the CCPA.
"CCPA" means California Civil Code Sec. 1798.100 et seq. (also known as the California Consumer Privacy Act of 2018).
"Consumer", "Business", "Sell" and "Service Provider" shall have the meanings given to them in the CCPA.
"Customer" refers to the Customer on a paid subscription plan with Holistics as described in the Terms, and all of its Affiliates.
"Customer Data" or "Customer Database" refers to all data residing in the Customer's database(s) and data source(s) connected to Holistics by Customer.
Customer End Users means the employees of the Customer who have been invited to access the Holistics Subscription Service in their customer account, or are in contact with Holistics.
"Data Protection Laws" means all applicable worldwide legislation relating to data protection and privacy which applies to the respective party in the role of Processing Personal Data in question under the Agreement, including without limitation European Data Protection Laws (EU and UK GDPR), the US CCPA, the Swiss FDPA, the Singapore PDPA, and the data protection and privacy laws of Australia; in each case as amended, repealed, consolidated or replaced from time to time.
"Data Subject" means the individual to whom "Personal Data" relates.
"Database Metadata" refers to the following categories of metadata from the customers' database which includes broadly (but not limited to):
User credentials of data source(s), applied with the necessary security encryption before storing in Holistics database.
The metadata (e.g. names of schemas, tables, fields, model relationships descriptions) of the database tables, excluding physical data record entries.
The metadata of definitions of objects created within the Holistics application (dashboards, data sets, data models, automated schedules).
Any other metadata that may be added from time to time.
"Europe" means the European Union, the European Economic Area and/or their member states, Switzerland, and the United Kingdom.
"European Data" means Personal Data that is subject to the protection of European Data Protection Laws.
"European Data Protection Laws" means data protection laws applicable in Europe, including:
Regulation 2016/679 of the European Parliament and of the Council (General Data Protection Regulation, "GDPR");
Directive 2002/58/EC concerning the processing of personal data and the protection of privacy in the electronic communications sector;
Applicable national implementations of (i) and (ii);
UK GDPR as it forms part of UK domestic law by virtue of Section 3 of the European Union (Withdrawal) Act 2018;
Swiss Federal Data Protection Act of 19 June 1992 and its Ordinance (“Swiss DPA”), as may be amended, superseded, or replaced.
"Instruction" means the written instruction, issued by Customer to Holistics, and directing the same to perform a specific action with regard to the Customer Database (including, but not limited to, depersonalising, blocking, deletion, making available). Instructions shall initially be specified in the Terms and may, from time to time thereafter, be amended, amplified or replaced by Customer in separate written instructions (individual instructions).
"PDPA" refers to the Personal Data Protection Act 2012 legislated in Singapore.
"Personal Data" means the personal data contained within the Customer Database, including any special categories of personal data defined under the Data Protection Laws of each jurisdiction, in each case processed by Holistics under the Terms.
"Personal Data Breach" means a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, Personal Data transmitted, stored or otherwise Processed by Holistics and/or its Sub-Processors in connection with the provision of the Subscription Services. "Personal Data Breach" shall not include unsuccessful attempts or activities that do not compromise the security of Personal Data, including unsuccessful log-in attempts, pings, port scans, denial of service attacks, and other network attacks on firewalls or networked systems.
"Process" or "Processing" means any operation or set of operations which is performed on Personal Data, encompassing the collection, recording, organization, structuring, storage, adaptation or alteration, retrieval, consultation, use, disclosure by transmission, dissemination or otherwise making available, alignment or combination, restriction or erasure of Personal Data.
"SCCs" means the Customer SCCs and/or SCCs as applicable, including:
Module 2: From a controller based in Europe to a processor (C2P)
Module 3: From a processor based in Europe to a processor (P2P)
UK SCC: From a controller based in UK to a Processor
"Sub-Processor" means any Processor engaged by Holistics or its Affiliates to assist in fulfilling the obligations with respect to the provision of the Subscription Services under the Agreement. Sub-Processors may include third parties or Affiliates but will exclude any Holistics employee or consultant.
"Temporary Cached Query Results" refer to all results provided to Customer, Customer End Users, or for System Consumption (APIs) for queries executed against Customer Database via Holistics for technical and performance reasons. These results are cached temporarily and will automatically expire after a specific time (minimum 10 minutes) after a unique SQL query is executed from the Customer Database.
"Terms" refers to the Terms of Service at https://www.holistics.io/terms.
### Introduction
This Data Processing Agreement ("DPA") reflects the parties' agreement with respect to the terms governing the Processing of data in the Customer Database under the Holistics Customer Terms of Service ("Terms"), and supersedes any previously signed DPA on an earlier date.
The DPA is an addon to, and forms an integral part of the Terms. It is effective upon its incorporation into the Terms, an online self-service purchase, or an Order or an executed amendment to the Agreement.
The terms "personal data", "data subject", "processing", "controller" and "processor" used in this DPA have the meanings given in the GDPR irrespective of whether European Data Protection Law or Non-European Data Protection Law applies.
The terms "Personal Data", "Customer Data", and "Customer Database" may be used interchangeably in this DPA.
This DPA shall follow the term of the Terms, including but not restricted to the Terms clauses
"Account Information from Third Party Providers"
"Limitation of Liability" and
"Indemnification" clauses.
In case of any conflict or inconsistency with the Terms, this DPA will take precedence to the extent of such conflict or inconsistency
The duration of Processing shall be the same as the duration of the Terms and this DPA.
The clauses of this DPA shall follow the Terms. Definitions not otherwise defined above herein shall have the meaning as set forth in the Terms.
### Holistics' Responsibilities
Holistics will only Process Customer Database for the purposes described in this DPA or as otherwise agreed within the scope of the Customer's Instructions, except where and to the extent otherwise required by applicable law.
Holistics will only access or use Customer Database to provide the Services ordered by Customer and will not use it for any other Holistics products, services, advertising, or to resell the data.
Holistics is not responsible for compliance with any Data Protection Laws applicable to the Customer's industry that are not applicable to us.
Holistics shall email the customer if we become aware of a confirmed breach and also further
Take any such reasonably necessary measures and actions to remedy or mitigate the effects of the Breach and
Keep the Customer informed of all material developments in connection with the Breach.
Provide reasonable information and cooperation so that the Customer can fulfill any data breach reporting obligations it may have under (and in accordance with the timescales required by) the applicable Data Protection law.
If any such request, correspondence, enquiry or complaint is made directly to the Holistics, Holistics will promptly inform the Customer providing full details of the same.
Holistics will take the appropriate technical and organisational measures (listed in Annex 2) to adequately protect Customer Database against misuse and loss in accordance with the requirements of the applicable national data protection law. Such measures hereunder shall include, but not be limited to,
the prevention of unauthorised persons from gaining access to Customer Database (physical access control),
the prevention of Customer Database from being accessed without authorisation (logical access control),
ensuring that Customer Database cannot be read, copied, modified or deleted without authorisation during electronic transmission and Holistics Software instance. (data transfer control),
Have a reasonable audit trail system in place to document whether and by whom information on Customer Database has been entered into, modified in, or removed from Customer Database (entry control),
ensuring that data from Customer Database are processed solely in accordance with the Instructions (control of instructions),
persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality,
Holistics Data Protection Team will provide prompt and reasonable assistance with any Customer queries related to processing of Customer Personal Data under the Agreement and can be contacted at %%CONTACT%%.
### Customer Responsibilities
Customer is responsible for complying with all applicable Data Protection Laws with respect to its Processing of Personal Data in the Customer Database connected to Holistics.
Customer shall retain title to their Customer Database connected to the Holistics Software instance and take technical safeguards to provision (and not over-provision) the appropriate level of data source connection for the user credentials supplied to Holistics.
Customer shall be solely responsible for
the accuracy, quality, and legality of Customer Database and the means in which Personal Data is acquired;
complying with all necessary transparency and lawfulness requirements under applicable Data Protection Laws for the collection and use of the Personal Data, including obtaining any necessary consents and authorizations (particularly for use by Customer for marketing purposes);
complying with the statutory requirements relating to data protection, in particular regarding safeguards against unauthorized access to Customer Database from Holistics software systems.
Customer shall inform Holistics without undue delay and comprehensively about any errors or irregularities related to statutory provisions on the Processing of Customer Database detected during a verification of the results of such Processing.
Customer is responsible for security relating to its environment and databases and security relating its configuration of the Software. This includes implementing and managing procedural, technical, and administrative safeguards on its software and networks sufficient to:
ensure the confidentiality, security, integrity, and privacy of Customer Database in transit, at rest, and in storage;
protect against any anticipated threats or hazards to the security and integrity of Customer Database; and
protect against any unauthorized processing, loss, use, disclosure or acquisition of or access to Customer Database.
Customer will minimize the sharing of Personal Data of Data Subjects in the support tickets and emails information sent to Holistics.
If such Personal Data needs to be included for troubleshooting, the Customer will deliberately add specific Instructions to handle such email communications.
For the avoidance of doubt, emails sent by the Customer with generic company email content confidentiality boilerplates appended by default will not be classified as confidential information.
Notwithstanding any other provision of this DPA, the Terms or any other agreement related to the Software and Services, Holistics has no obligations or liability as to any breach or loss resulting from:
The Customer's environment, databases, systems or software, or
The Customer's security configuration or administration of the Software.
Customer is solely responsible for provisioning Users on the Software, including:
methods of authenticating Users (such as industry-standard secure username/password policies, two-factor authentication etc);
Restricting access by User or group, and from the database level down to the row or column level;
Managing admin privileges;
deauthorizing personnel who no longer need access to the Software;
setting up any API usage in a secure way; and
regularly auditing any public access links Users create and restricting the permission to create public links, as necessary.
Customer is responsible to remove the network connection between Customer Database and the Holistics Software Instance should they terminate the Subscription Service.
Customer may choose to connect or enable integrations with third-party services using the Customer's own accounts or credentials (for example, messaging, spreadsheet, source control, or data transformation tools). Such third-party services are not Holistics sub-processors; the Customer's use of them is governed by the Customer's own agreement with the relevant provider, and Holistics transmits data to them solely on the Customer's Instruction.
### Customer Database Sub-Processors
Customer consents to Holistics engaging affiliates and third party sub-processors to process data in Customer Database for the purpose as described in the Terms.
Holistics will maintain an up-to-date list of its sub-processors. For avoidance of doubt, the above consent constitutes Customer's prior written consent to the sub-Processing by Holistics (Annex 3)
Holistics will impose data protection terms on any sub-processor it appoints as required to protect Customer Data to the standard required by the Data Protection Laws.
If Holistics intends to instruct sub-Processors other than the companies listed in Annex 3, Holistics will notify the Customer thereof in writing (email to the email address(es) on record in Processor's account information for Customer is sufficient) and will give the Customer the opportunity to object to the engagement of the new sub-Processors within 30 days after being notified.
The objection, if raised, must be based on reasonable grounds (e.g. if the Customer proves that significant risks for the protection of its Customer Data exist at the sub-Processor).
In such an event, Holistics will either not appoint or replace the sub-processor or, if this is not possible, Customer may suspend or terminate the Terms (without prejudice to any fees incurred by Customer prior to suspension or termination).
**Data Transfers**
Customer acknowledges and agrees that Holistics may access and process Customer Data on a global basis as necessary to provide the Subscription Service in accordance with the Agreement, and in particular that Customer Data may be transferred to the data centre location(s) that Holistics operates in.
Holistics may store and process (i) Holistics Metadata and Usage Data and (ii) Temporary Cached Query Results anywhere Holistics or its Sub-processors maintain facilities, subject to Sections on Additional Provisions for European Data, Additional Provisions for California Personal Information, or other jurisdictions where Holistics operates in.
The physical data records residing in Customer Database will not be stored permanently by Holistics application servers outside of the purpose set in the Terms.
Temporary Cached Query Results needed to visualize the dashboard data will be temporarily stored in Holistics, and will automatically expire after a specific time duration.
Wherever Personal Data is transferred outside its country of origin, each party will ensure such transfers are made in compliance with the requirements of Data Protection Laws.
### Provisions Specific for European Data
The parties acknowledge and agree that European Data Protection Law will apply to the processing of Customer Data if:
The processing is carried out in the context of the activities of an establishment of Customer in the territory of the EEA or the UK; and/or
Customer Personal Data is personal data relating to data subjects who are in the EEA or the UK and the processing relates to the offering to them of goods or services in the EEA or the UK, or the monitoring of their behavior in the EEA or the UK.
Definitions:
"Controller" means the natural or legal person, public authority, agency or other body which, alone or jointly with others, determines the purposes and means of the processing of Personal Data.
"Processor" means a natural or legal person, public authority, agency or other body which processes Personal Data on behalf of the Controller.
Relationship between Customer and Holistics:
Holistics is the Processor of the Customer Database for the purposes described in the Terms. Holistics is the Processor regardless of whether Customer is itself a controller or a processor of that data; the distinction below determines only which Module of the EU SCCs applies, not Holistics' role.
The EU SCCs (Annex 4) incorporate both Modules, and the applicable Module is determined automatically by Customer's role with respect to the relevant data (no separate election is required):
where, and to the extent that, Customer is the Controller of data (which may include Personal Data and Data Subjects) stored in the Customer Database, SCC Module 2 (Controller to Processor) applies;
where, and to the extent that, Customer is the Processor of data stored in the Customer Database (for example, where Customer embeds the Subscription Services and processes its own customers' data on their behalf), SCC Module 3 (Processor to Processor) applies.
Where Customer's role is mixed, each Module applies to the corresponding processing.
Holistics and the Customer shall be separately responsible for conforming with such statutory data protection regulations as are applicable to them.
Legacy MCCs: The SCCs will, as of the Transition Date, supersede and terminate any Model Contract Clauses approved under Directive 95/46/EC and previously entered into by Customer and Holistics.
The Transition Date means:
October 27, 2021, if (a) Customer’s billing address is outside EMEA, and (b) the processing of Customer Personal Data is subject to European Data Protection Law.
Otherwise, September 27, 2021.
Data Protection Impact Assessments and Consultation with Supervisory Authorities: Holistics will (taking into account the nature of the processing and the information available to Holistics) assist Customer in ensuring compliance with its (or, where Customer is a processor, the relevant controller's) obligations under Articles 35 and 36 of the GDPR, by:
Providing and updating our public documentation on technical security measures (see: /docs/security-compliance/data-security);
Providing public documentation on how Holistics caching and job queuing mechanism work (see: /docs/performance/data-caching);
Providing the Security Measures (Annex 2) contained in the Agreement including these Terms; and
if the above subsections are insufficient for Customer (or the relevant controller) to comply with such obligations, upon Customer's request, providing Customer with additional reasonable cooperation and assistance.
Transfer Mechanism for Data Transfers:
Permitted Transfers: The parties acknowledge that European Data Protection Law does not require SCCs or an Alternative Transfer Solution in order for Customer Personal Data to be processed in or transferred to an Adequate Country ("Permitted Transfers").
Restricted Transfers: If the processing of Customer Personal Data is not processed in an Adequate Country, and European Data Protection Law applies to those transfers, then
The EU SCCs (EU Controller-to-Processor) will apply with respect to Restricted Transfers between Customer and Holistics that are subject to the EU GDPR and/or the Swiss FDPA; and
the UK SCCs (UK Controller-to-Processor) will apply (regardless of whether Customer is a controller and/or processor) with respect to Restricted Transfers between Customer and Holistics that are subject to the UK GDPR.
Holistics agrees to abide by and process European Data in compliance with the Standard Contractual Clauses.
Although Holistics does not rely on the Singapore Personal Data Protection Act 2012 ("PDPA") as a legal basis for transfers of Personal Data, Holistics will inform Customer if it is unable to comply with this requirement if any conflicts arise.
The parties agree that for the purposes of the Standard Contractual Clauses:
Holistics will be the "data importer" and Customer will be the "data exporter" (on behalf of itself and Permitted Affiliates);
the Annexes of the Standard Contractual Clauses shall be populated with the relevant information set out in Annex 1 and Annex 2 of this DPA;
if and to the extent the Standard Contractual Clauses conflict with any provision of this DPA, the Standard Contractual Clauses will prevail to the extent of such conflict.
To the extent that and for so long as the Standard Contractual Clauses as implemented in accordance with this DPA cannot be relied on by the parties to lawfully transfer Personal Data in compliance with the GDPR, the applicable standard data protection clauses issued, adopted or permitted under the GDPR shall be incorporated by reference, and the annexes, appendices or tables of such clauses shall be deemed populated with the relevant information set out in Annex 1 and Annex 2 of this DPA.
Demonstration of Compliance:
Holistics will make all information reasonably necessary to demonstrate compliance with this DPA available to Customer and allow for and contribute to audits, including inspections conducted by or an auditor appointed by Customer in order to assess compliance with this DPA.
Customer acknowledges and agrees to exercise audit rights under this DPA and Clause 8 of the Standard Contractual Clauses by instructing Holistics to comply with the audit measures described in this 'Demonstration of Compliance' section.
Customer acknowledges that the Subscription Service is hosted by our data center partners (listed in our sub-processors) who maintain independently validated security programs.
Holistics may charge a fee (based on Holistics' reasonable costs) for any audit under Demonstration of Compliance. Holistics will provide the Customer with further details of any applicable fee, and the basis of its calculation, in advance of any such audit. Customer will be responsible for any fees charged by any auditor appointed by Customer to execute any such audit.
Holistics may object in writing to an auditor appointed by Customer to conduct any audit under Demonstration of Compliance if the auditor is, in Holistics' reasonable opinion, not suitably qualified or independent, a competitor of Holistics, or otherwise manifestly unsuitable. Any such objection by Holistics will require the Customer to appoint another auditor or conduct the audit itself.
Processing Records: Holistics will keep appropriate documentation of its processing activities. To the extent the GDPR requires Holistics to collect and maintain records of certain information relating to Customer, Customer will, where requested, supply such information to Holistics and keep it accurate and up-to-date. Holistics may make any such information available to the Supervisory Authorities if required by the GDPR.
No Modification of SCCs: Nothing in the Agreement (including these Terms) is intended to modify or contradict any SCCs or prejudice the fundamental rights or freedoms of data subjects under European Data Protection Law.
### Provisions Specific for California Personal Information
This section will apply only with respect to California Personal Information residing in Customer Database.
When processing California Personal Information in accordance with Customer's Instructions, the parties acknowledge and agree that Customer is a Business and Holistics is a Service Provider for the purposes of the CCPA.
Both parties agree that Holistics will Process California Personal Information as a Service Provider strictly for the purpose of performing the Subscription Services or as otherwise permitted by the CCPA, including as described in our Terms.
### Limitation of Liability
Each party's liability, taken together in the aggregate, arising out of or related to this DPA, and all DPAs between Customer and Holistics, whether in contract, tort or under any other theory of liability, is subject to the 'Limitation of Liability' section of the Terms, and any reference in such section to the liability of a party means the aggregate liability of that party under the Agreement and all DPAs together.
For the avoidance of doubt, Holistics' total liability for all claims from the Customer arising out of or related to the Agreement and each DPA shall apply in the aggregate for all claims under both the Agreement and all DPAs established under the Agreement by the Customer.
### Governing Law and Disputes
This DPA will be governed by and construed in accordance with the laws of the Singapore, unless otherwise required by
EU Data Protection Law, in which case this DPA will be governed by the laws of the Member State in which the Customer is established.
CCPA, in which case this DPA will be governed by the laws of California, USA.
the Data Protection Laws of each jurisdiction the Customer operates in
If Holistics becomes aware that Customer Data cannot be processed in accordance with the Customer's Instructions due to a legal requirement under any applicable law, Holistics will
promptly notify Customer that legal requirement to the extent permitted by the applicable law; and
where necessary, cease all Processing (other than merely storing and maintaining the security of the affected Customer Data) until such time as the Customer issues new Instructions with which Holistics is able to comply. If this provision is invoked, Holistics will not be liable to the Customer under the Agreement for any failure to perform the applicable Subscription Services until such time as Customer issues new lawful Instructions with regard to the Processing.
Arb-Med-Arb: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore.
The Tribunal shall consist of one (1) arbitrator(s)
The language of the arbitration shall be English
### Core documents and annexes
These documents always form part of this DPA:
- This Data Processing Agreement (DPA), as defined in [https://docs.holistics.io/legal/dpa](https://docs.holistics.io/legal/dpa)
- Holistics Terms of Service (Terms), as defined in [https://holistics.io/terms](https://holistics.io/terms)
- [Annex 1: Subject Matter and Details of Data Processing](/legal/annex-subject-matter)
- [Annex 2: Security Measures](/legal/annex-security-measures) (Technical and Organisational Measures to ensure the security of the data)
- [Annex 3: List of Holistics Sub-Processors](/legal/annex-sub-processors)
### Selective annexes
These annexes apply to the Customer where relevant:
- [Annex 4: EU Standard Contractual Clauses](/legal/annex-eu-scc) (incorporating Module 2 (Controller to Processor) and Module 3 (Processor to Processor); the applicable Module is determined by Customer's role)
- [Annex 5: UK SCC (Controller to Processor)](/legal/annex-uk-scc)
%%SIGNATORY%%
---
## Data security [archived 2026-06-11]
:::warning Archived version
This is the **11 June 2026** version of this document, preserved for historical reference. It is no longer maintained. For the current version, see [the live document](/docs/security-compliance/data-security).
:::
## Is Holistics GDPR-compliant?
Yes we are. Please see our [GDPR page](/legal/archive/gdpr/2022-07-06) for more information.
## Is Holistics SOC2-compliant?
Yes we are. Please see our [SOC2 Compliance page](/docs/security-compliance/soc2) for more information.
## Where are Holistics' servers located?
All of our servers are hosted with reputable data center providers:
- Our Asia-Pacific servers are located in Singapore
- Our Europe servers are located in Frankfurt, Germany
- Our US servers are located in San Francisco
## Does Holistics store my data?
Holistics **does not** **store** your raw data in our servers. This means that your data sits securely within your system at all times. You retain full ownership and control over your data.
When a user runs a report or explores a dataset, Holistics will generate and send an SQL query to your database for processing. Once the query completes, Holistics will display the results on your browser.
Holistics **only stores** a few things:
- **Metadata**: the definitions and settings of your reports, dashboards, models... etc. For examples: report's query, description, chart type, delivery schedules...
- **Cache**: Holistics gives you the option to set a cache for your reports, dashboards and filters. This speeds up access to your data and also protects your database against repeated queries. Cached data will expire after a determined period of time. No cache data will ever be stored forever on Holistics servers.
### What does your cache store?
The cache layer only stores the **query results** (not the raw data of your database). For further technical details of Holistics's cache system, please refer to our docs about [Holistics Reporting Mechanism](/docs/performance/data-caching).
### When exactly does your cache store the data, and for how long?
Our cache server stores your query results in two instances:
1. **Initial Retrieval**: When we first fetch the query result from your database, you can set the data caching duration in the Settings tab of the report.
2. **Report Export**: When someone exports a report to Excel/CSV, we generate and store the file on our AWS S3 server. The encrypted files are automatically removed after 24 hours.
For personalized storage on your S3 cloud, available in the Enterprise plan, please request this through your account admin(s) via an in-app support ticket.
### When i persist my SQL data model into a physical table, where is the table stored?
As outlined in the documentation on [Query Model Persistence](/docs/query-models#model-persistence), optimizing the load time of your SQL model involves transforming the result set of the SQL query into a physical table **within your database**.
Similar to reports, dashboards, filters, and other elements, Holistics only stores the metadata of the model (including SQL, model description, custom field formulas, etc.).
## My database is behind a private firewall. how do i give access to Holistics?
As mentioned in [Connect Database](/docs/connect/connect.md), to securely open your DB for Holistics to access, there are two ways:
- **IP Whitelist**: You can add Holistics' IP addresses to your whitelist so that Holistics can connect to your DB. For more information, please refer to [Direct Connection](/docs/connect/connect-direct.md) section.
- **SSH Tunnel**: You can setup a secure SSH tunnel from your DB to Holistics Network, so that all traffic will go through this channel. For more information, please refer to [Setup Reverse SSH Tunnels](/docs/connect/connect-tunnel/) section.
## Since our database credentials are stored in Holistics's system, how do you protect them?{#credentials-protection}
We apply AES encryption before storing your credentials in our database. The credentials are then decrypted on the fly whenever we make a connection to your DB server, and the raw credentials are never persisted anywhere. The encryption key resides in a server separated from the DB server.
Our DB server is under a private VPC network and is only accessible by our app servers.
## How do i track what data/reports my team has accessed?
Please refer to [Monitoring Dashboard](/docs/monitoring) for more information.
---
## GDPR statement (effective 20 August 2021)
:::warning Superseded version
This is the **20 August 2021** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current GDPR statement](/legal/gdpr).
:::
_Updated: 20 August 2021_
As a data company, we take our responsibility to protect the data of our customers very seriously. Europe’s General Data Protection Regulation (GDPR) came into effect on 25 May 2018. This broad reaching regulation is designed to ensure the privacy, security, and confidentiality of data.
**Holistics is GDPR-compliant.** To request more information, please contact us at [support@holistics.io](mailto:support@holistics.io).
## Terms of Service
We have [updated and reviewed our Terms of Service](/legal/archive/terms-of-service/2020-06-02) to better communicate our responsibilities towards our users and their data, and reiterate our commitment towards transparency of our practices.
## Privacy Policy
We have clearly outlined in our Privacy Policy the ways we collect, use, process and share personal information. Our Privacy Policy outlines how you can exercise your rights to any personal data that you share with us, and we will always respect your choice to receive or opt-out from communications with us. More details can be found in our [Privacy Policy](/legal/archive/privacy-policy/2018-05-25) here.
You can always submit a request to us to review, edit, or delete your Personal Information by emailing your request to privacy@holistics.io.
## Data Processing Agreement
You can read the Data Processing Agreement [here](/legal/archive/data-processing-agreement/2021-12-13).
---
## GDPR statement (effective 6 July 2022)
:::warning Superseded version
This is the **6 July 2022** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current GDPR statement](/legal/gdpr).
:::
_Updated: 6 July 2022_
As a data company, we take our responsibility to protect the data of our customers very seriously. Europe’s General Data Protection Regulation (GDPR) came into effect on 25 May 2018. This broad reaching regulation is designed to ensure the privacy, security, and confidentiality of data.
**Holistics is GDPR-compliant.** To request more information, [please contact us](https://www.holistics.io/contact-us/).
## Terms of service
We have [updated and reviewed our Terms of Service](/legal/archive/terms-of-service/2020-06-02) to better communicate our responsibilities towards our users and their data, and reiterate our commitment towards transparency of our practices.
## Privacy policy
We have clearly outlined in our Privacy Policy the ways we collect, use, process and share personal information. Our Privacy Policy outlines how you can exercise your rights to any personal data that you share with us, and we will always respect your choice to receive or opt-out from communications with us. More details can be found in our [Privacy Policy](/legal/archive/privacy-policy/2018-05-25) here.
You can always submit a request to us to review, edit, or delete your Personal Information please contact us by submitting an in-app support ticket if you are a current user or [contact us](https://www.holistics.io/contact-us/).
## Data processing agreement
You can read the Data Processing Agreement [here](/legal/archive/data-processing-agreement/2022-03-15).
---
## Legal document archive
This archive keeps superseded versions of our legal and compliance documents so you can trace how each one evolved. Versions are named by their effective date (the "Updated" date printed on the document). These pages are no longer maintained. For the version that applies today, always use the live document.
## Data Processing Agreement (DPA)
Current version: [Data Processing Agreement](/legal/dpa) (12 June 2026).
| Effective date | Archived version |
| --- | --- |
| 28 May 2026 | [View](/legal/archive/data-processing-agreement/2026-05-28) |
| 24 August 2022 | [View](/legal/archive/data-processing-agreement/2022-08-24) |
| 15 March 2022 | [View](/legal/archive/data-processing-agreement/2022-03-15) |
| 13 December 2021 | [View](/legal/archive/data-processing-agreement/2021-12-13) |
## Terms of Service
Current version: [Terms of Service](/legal/terms) (14 May 2025).
| Effective date | Archived version |
| --- | --- |
| 24 December 2024 | [View](/legal/archive/terms-of-service/2024-12-24) |
| 3 October 2024 | [View](/legal/archive/terms-of-service/2024-10-03) |
| 24 May 2024 | [View](/legal/archive/terms-of-service/2024-05-24) |
| 20 March 2023 | [View](/legal/archive/terms-of-service/2023-03-20) |
| 2 June 2020 | [View](/legal/archive/terms-of-service/2020-06-02) |
## Privacy Policy
Current version: [Privacy Policy](/legal/privacy-policy) (12 June 2026).
| Effective date | Archived version |
| --- | --- |
| 29 December 2023 | [View](/legal/archive/privacy-policy/2023-12-29) |
| 25 May 2018 | [View](/legal/archive/privacy-policy/2018-05-25) |
## GDPR Statement
Current version: [GDPR Statement](/legal/gdpr) (12 June 2026).
| Effective date | Archived version |
| --- | --- |
| 6 July 2022 | [View](/legal/archive/gdpr/2022-07-06) |
| 20 August 2021 | [View](/legal/archive/gdpr/2021-08-20) |
## DPA Annexes
The annexes do not carry their own effective date. This is the version as it stood just before the 12 June 2026 revision (which reworked them and split the SCC annexes into separate pages).
| Document | Archived version | Current version |
| --- | --- | --- |
| Annex 1: Subject Matter | [Pre-revision (11 Jun 2026)](/legal/archive/annex-subject-matter/2026-06-11) | [Live](/legal/annex-subject-matter) |
| Annex 2: Security Measures | [Pre-revision (11 Jun 2026)](/legal/archive/annex-security-measures/2026-06-11) | [Live](/legal/annex-security-measures) |
| Annex 3: Sub-processors | [Pre-revision (11 Jun 2026)](/legal/archive/annex-sub-processors/2026-06-11) | [Live](/legal/annex-sub-processors) |
## Data Security
| Document | Archived version | Current version |
| --- | --- | --- |
| Data Security | [Pre-revision (11 Jun 2026)](/legal/archive/data-security/2026-06-11) | [Live](/docs/security-compliance/data-security) |
---
## Privacy policy (effective 25 May 2018)
:::warning Superseded version
This is the **25 May 2018** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Privacy policy](/legal/privacy-policy).
:::
_Updated: 25 May 2018_
Holistics understands you care how information about you is collected and used. Holistics is committed to protecting the privacy of individuals who interact with us. This Holistics Privacy Policy (“Privacy Policy”) describes Holistics Software Pte Ltd and its affiliated entities (collectively “Holistics”) privacy practices for our websites, services, and apps that link to this policy. This policy also details our privacy practices related to Holistics marketing, advertising, and company events.
In this Privacy Policy, we will refer to the Holistics Data Platform and related services collectively as our "Services." We will refer to our emails, newsletters, and other marketing and advertising practices collectively as our “Communications.” For the purpose of this Privacy Policy, “Personal Information” means any information relating to an identified or identifiable natural person.
[Description of Users and Acceptance of Terms](#description-of-users-and-acceptance-of-terms)
[Information Collected by Holistics](#information-collected-by-holistics)
[Contact Information](#contact-information)
[Billing Information](#billing-information)
[Information Tracking Technologies](#information-tracking-technologies)
[Trial Sign-Ups](#trial-sign-ups)
[Information from Support and Success Channels](#information-from-support-and-success-channels)
[Information Collected from Third Parties](#information-collected-from-third-parties)
[Information Stored on Holistics](#information-stored-on-holistics)
[As a Controller of Personal Information](#as-a-controller-of-personal-information)
[As a Processor of Personal Information](#as-a-processor-of-personal-information)
[How We Use Personal Information Personal](#how-we-use-personal-information-personal)
[Information Sharing Information security and storage](#information-sharing-information-security-and-storage)
[Cross-border data transfers](#cross-border-data-transfers)
[How Long Do We Retain Your Personal Information?](#how-long-do-we-retain-your-personal-information)
[Reviewing, updating or deleting your Personal Information](#reviewing-updating-or-deleting-your-personal-information)
[Opting-out of Holistics Communications](#opting-out-of-holistics-communications)
[Minimum Age](#minimum-age)
[For More Information](#for-more-information)
## Description of Users and Acceptance of Terms
This Privacy Policy applies to visitors to the Site ([“www.holistics.io”](https://www.holistics.io)), who view only publicly-available content (the “Visitors”) and subscribers (the “Subscribers”) who have signed up to access and use our platform (the “Platform”).
By visiting our Site, Visitors are agreeing to the terms of this Privacy Policy and the accompanying Website Terms of Service. By signing up, accessing, and/or using the Platform, each Subscriber is agreeing to the terms of this Privacy Policy and the accompanying Terms of Service (“TOS”)
## Information Collected by Holistics
### Contact Information
When you contact us through the “Contact Us” page, or sign up to become a Subscriber, you will be asked to provide certain information which may include First name, Last name, Email address, Job title, Company name, Country and mobile phone number (“Contact Information”). The Contact Information is used to provide the requested Service or information, and to contact subscribers and visitors for purposes of direct marketing of our current and future Services.
We retain Contact Information to send product updates, relevant marketing, training and events based on the users’ communication preferences.
### Billing Information
In order to purchase a subscription to our Platform, you will be required to provide certain additional information which may include a credit card number, expiration date, billing zip code, activation code, and similar information (“Billing Information”).
Billing Information is collected and processed by our third-party payment processor operating as our agent. Holistics does not directly store, obtain or process any Billing Information within our own application.
### Information Tracking Technologies
We receive and store certain information about how you use our websites and Services when you visit them through the use of Information Tracking Technologies (“ITT”), which include first-party and third-party cookies, logs, web beacons, and other similar technologies. Our servers collect similar information when you are logged into our website or Services.
The information we receive through ITT may be associated with you, depending on the website or Services you are using, and whether you have provided information identifying yourself to the website or Services.
Cookies are identifiers we transfer to your browser or device that allow us to recognize your browser or device and tell us how and when pages and features in our Services are visited and by how many people.
For example, we receive information that your browser or device sends to our servers whenever you visit a Holistics website. Your browser or device may tell us your internet protocol (IP) address used to connect your computer to the Internet, computer and connection information such as browser type, version, language and time zone settings, browser plug-in types, operating system, and type of device you are using.
When you visit our Site, your browser may also tell us information such as the actions you take on our Site, the page that led you to our Site and, if applicable, the search terms you typed into a search engine that led you to our Site.
You may be able to change the preferences on your browser or device to prevent or limit your device’s acceptance of cookies, but this may prevent you from taking advantage of some of our features. We may use this data to customize content for you that we think you might like, based on your usage patterns, and generally to improve the Services.
We use ITT to collect information about your interactions with the Site (“Usage Data”) and how the Site is performing (“Analytics Data”). Usage Data may include information regarding any interaction you have with the Site, such as which functionalities are used and the frequency of use (e.g., pages visited, actions taken, queries run, user accounts, account roles, and connected database types). Analytics Data may include query response times, application response times and other metrics that monitor the responsiveness of the Site.
In addition, third parties may be able to collect information about your online activities when you use our websites or Services from ITT. We do not respond to web browser ‘do not track’ signals or other similar transmissions that indicate a request to disable online tracking of users who visit our websites or who use our websites or Services.
If you receive emails from us, we may use certain analytics tools, to capture data such as when (and where) you opened our email or click on any links or banners our email contains. This data helps us to gauge the effectiveness of our communications and marketing campaigns.
### Trial Sign-Ups
When you sign up for a trial, we collect your work email and rejects the use of common personal publicly available email domain accounts. We will send product, marketing and sales related emails designed to help facilitate your product evaluation.
Holistics use third party tools to help you onboard effectively, such as setting up user onboarding flows, automate pop-up message, or to study in-app activity of trial users to identify and clear obstacles for them to have a successful onboarding experience.
### Information from Support and Success Channels
Information you provide through our support and success channels will be stored. The Services also include our customer support and customer success, where you may choose to submit information regarding a problem you are experiencing with a Service.
Whether you designate yourself as a technical contact, open a support ticket, speak to one of our representatives directly or otherwise engage with our support team, you will be asked to provide contact information, a summary of the problem you are experiencing, and any other documentation, screenshots or information that would be helpful in resolving the issue.
From time to time, our support team may temporarily access your tenant for the sole purpose of prompt resolution and fast troubleshooting. All such access attempts are logged internally and associated with a support ticket.
Information sent to our group emails for support and customer success will be accessible by our engineering and business teams on duty to provide you a more holistic and responsive service level. If any of such data needs to be shared, please indicate in your email communications that such data is sensitive and confidential.
For the avoidance of doubt, emails sent with generic company confidentiality boilerplates footers at all emails appended by default will not be classified as such sensitive/confidential information unless otherwise stated.
### Information Collected from Third Parties
We maintain pages on online social networks and advertising sites. We may collect information when you interact with our social network pages.
We advertise online, including displaying Holistics ads across the Internet on websites and in apps. When we advertise online to you, we may collect information about which ads are displayed to you, which ads you click on, and the web page where the ad was displayed to you.
## Information Stored on Holistics
We act as both a Controller and Processor of Information Stored for our customers.
### As a Controller of Personal Information
We store data on Holistics customers and visitors on our Site and Service. This includes details such as trial sign-up information, login details (encrypted), as well as metadata about product usage. Metadata is used to facilitate product improvements, customer support and license auditing.
### As a Processor of Personal Information
Where our Customer Database (described in our terms of service) is connected to Holistics, and the Customer Database contains Personal Information necessary to answer queries from Holistics users, Holisics acts as a processor of Personal Information.
Holistics does not store or sync a copy of your data directly from source, so your data remains stored on your own central servers.
You can provide read-only connection to access the minimum amount of data needed to answer your questions in your query results. You may choose to provide Holistics write-access to your database for our ETL features, where Holistics will help you move data across your data sources, or run in-database transformation to speed up query performance.
You may also leverage on the Holistics cache, which speeds up your dashboard/report access time by preloading your query results at scheduled intervals of your choosing. This cache is set to automatically expire at your preferred duration, and can be turned off on a per report basis. You can choose to remove the cache data at any time for your query.
## How We Use Personal Information
Holistics may uses your Personal Information to:
* Provide you with Holistics website content, the features, functions and benefits of the Service.
* Respond to your requests for information, products, or services, and to provide customer service and support.
* Operate, maintain and improve our websites and Services (such as, for the purposes of fixing malfunctions, testing our security systems, etc.)
* Provide you with notices related to your use of the Service.
* Personalize our website, Services, and Communications to your likely interests and
needs
* Send you business messages such as those related to Services notifications,
payments or renewal of your subscription, or process billing for your Service subscription (i.e. charging your credit card).
* Provide you with promotional and marketing emails. You can opt-out of receiving certain types of promotional and marketing emails, but if you do you may not receive the full benefit of the Service. Opting-out can be done by following the instructions at the bottom of the promotional material.
* Contact you via telephone to discuss our Services and related offers with you
* Personalize the Service experience for you (such as, remembering your information
so you will not have to enter it each time you use the Service).
* Enhance, improve and further develop the Service (such as, creating new features or
functions, refining the user experience, increasing Service technical performance,
etc.).
* Enable advertising delivered to you to be more relevant
* Email you periodically (intervals of months) to to explore if you like to re-evaluate our
Services if you have previously tried us.
* For other purposes about which we notify you.
From time to time, we may provide information to our customers and potential customers in the form of electronic or print newsletters. When you subscribe to our newsletter you may be added to our mailing list and will receive announcements and information about Holistics. It will be emailed or mailed to the address that you provide when you subscribe.
Holistics’ third-party service providers serve ads on our behalf across the Internet. Some of these ads may be personalized for you based on information collected from your use of the Site.
## Personal Information Sharing
In certain circumstances, Holistics may share your Personal Information with third-party service providers that provide the below services for Holisics:
* Email communications (operational, marketing).
* Customer Relationship Management
* Data management.
* Database hosting.
* Payment card processing.
* Helpdesk services
* Shipping services.
* Collaboration services
* Cloud services.
* Online Advertisements
We will only pass your data on to third parties without your express consent if we are obliged to do so by statutory law or an instruction by a public authority or court.
## Information security and storage
We understand that the security of your Personal Information is important. We implement reasonable administrative, technical, and physical security controls designed to protect your Personal Information from loss, misuse, unauthorized access, disclosure, alteration, or destruction. However, despite our efforts, no security controls are completely effective and we cannot ensure or warrant the security of your Personal Information.
Your Personal Information and data files are stored on our servers and the servers of our affiliated companies and companies we hire to provide services to us.
## Cross-border data transfers
Your Personal Information may be stored in Singapore, the United States, where our employees reside, or transferred to other countries where the companies we hire to help us run our business are located. Those countries may not have the same data protection laws as the country in which you initially provided the information. When we transfer your Personal Information, we will protect it as described in this privacy policy.
## How Long Do We Retain Your Personal Information?
We will retain personal information for as long as it is needed for legitimate business purposes to fulfil the purposes we collected it for, including for the purposes of satisfying any legal, accounting, or reporting requirements. We keep such servers to help protect the stability and availability of the Service (such as protecting it from viruses and malfunctions).
To determine the appropriate retention period for personal information, we consider the amount, nature, and sensitivity of the personal information, the potential risk of harm from unauthorised use or disclosure of your personal information, the purposes for which we process your personal information and whether we can achieve those purposes through other means, and the applicable legal requirements.
Some of your Personal Information may also remain on backup systems or third-party services after your use of our websites and/or Services ends, unless you request that your data be deleted.
Details of retention periods for different aspects of your personal information are available in our retention policy which you can request from us by contacting us. See the **“Reviewing, updating or deleting your Personal Information”** section below.
## Reviewing, updating or deleting your Personal Information
We take reasonable steps intended to ensure that your Personal Information we collect is accurate, complete, and current by using the most recent information provided to us.
Our websites and Services may allow you to review and edit your Personal Information by accessing your profile or similar feature of the website or Service you are using.
For our websites, you may have the ability to manage your cookies and similar technologies through your web browser settings. You should consult the settings and instructions provided by the provider of your web browser for more information.
You may also submit a request to us to review, edit, or delete your Personal Information by emailing your request to privacy@holistics.io
Our business hours for telephone contact are 9:00 AM to 6:00 PM Singapore Time (GMT+8). Once we verify your identify, we will assist you with your request.
You can choose not to provide us with your Personal Information, but if you do not provide us with your Personal Information when we request it, we may not be able to provide you with the websites and Services you use, or tailor them to you.
### Opting-out of Holistics Communications
We may occasionally send you notification emails about updates to our product, legal documents, offer customer support or marketing emails. Except for cases where we are required to do so by law (e.g. notifying you of a data breach), you shall have the opportunity to unsubscribe from receiving these messages free of charge.
You may opt out of receiving Communications by modifying your website or Service profile, or by unsubscribing to the marketing mailings or newsletters you no longer desire. To unsubscribe, please follow the "Unsubscribe" instructions that are contained within the mailing, newsletter or other Communication that we send to you.
You may also send an email to privacy@holistics.io with "Unsubscribe" in the body, together with a description of the Communications you no longer desire to receive.
### Minimum Age
The Site is not directed to, nor intended to be used by, individuals under the age of 13. Holistics does not knowingly collect personal information from individuals under the age of 13. If you become aware that an individual under the age of 13 has provided us with personal information, please contact us immediately at privacy@holistics.io. If we become aware that an individual under the age of 13 has provided us with personal information, we will take steps to delete such information.
### For More Information
If you have any questions or concerns about this Privacy Policy, please contact us at privacy@holistics.io or 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545.
---
## Privacy policy (effective 29 December 2023)
:::warning Superseded version
This is the **29 December 2023** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Privacy policy](/legal/privacy-policy).
:::
_Updated: 29 Dec 2023_
Holistics understands you care how information about you is collected and used. Holistics is committed to protecting the privacy of individuals who interact with us. This Holistics Privacy Policy (“Privacy Policy”) describes Holistics Software Pte Ltd and its affiliated entities (collectively “Holistics”) privacy practices for our websites, services, and apps that link to this policy. This policy also details our privacy practices related to Holistics marketing, advertising, and company events.
In this Privacy Policy, we will refer to the Holistics Data Platform and related services collectively as our "Services." We will refer to our emails, newsletters, and other marketing and advertising practices collectively as our “Communications.” For the purpose of this Privacy Policy, “Personal Information” means any information relating to an identified or identifiable natural person.
[Description of Users and Acceptance of Terms](#description-of-users-and-acceptance-of-terms)
[Information Collected by Holistics](#information-collected-by-holistics)
[Contact Information](#contact-information)
[Billing Information](#billing-information)
[Information Tracking Technologies](#information-tracking-technologies)
[Trial Sign-Ups](#trial-sign-ups)
[Information from Support and Success Channels](#information-from-support-and-success-channels)
[Information Collected from Third Parties](#information-collected-from-third-parties)
[Information Stored on Holistics](#information-stored-on-holistics)
[As a Controller of Personal Information](#as-a-controller-of-personal-information)
[As a Processor of Personal Information](#as-a-processor-of-personal-information)
[How We Use Personal Information](#how-we-use-personal-information)
[Information security and storage](#information-security-and-storage)
[Cross-border data transfers](#cross-border-data-transfers)
[How Long Do We Retain Your Personal Information?](#how-long-do-we-retain-your-personal-information)
[Reviewing, updating or deleting your Personal Information](#reviewing-updating-or-deleting-your-personal-information)
[Opting-out of Holistics Communications](#opting-out-of-holistics-communications)
[Minimum Age](#minimum-age)
[For More Information](#for-more-information)
## Description of Users and Acceptance of Terms
This Privacy Policy applies to visitors to the Site ([“www.holistics.io”](https://www.holistics.io)), who view only publicly-available content (the “Visitors”) and subscribers (the “Subscribers”) who have signed up to access and use our platform (the “Platform”).
By visiting our Site, Visitors are agreeing to the terms of this Privacy Policy and the accompanying Website Terms of Service. By signing up, accessing, and/or using the Platform, each Subscriber is agreeing to the terms of this Privacy Policy and the accompanying Terms of Service (“TOS”)
## Information Collected by Holistics
### Contact Information
When you contact us through the “Contact Us” page, or sign up to become a Subscriber, you will be asked to provide certain information which may include First name, Last name, Email address, Job title, Company name, Country and mobile phone number (“Contact Information”). The Contact Information is used to provide the requested Service or information, and to contact subscribers and visitors for purposes of direct marketing of our current and future Services.
We retain Contact Information to send product updates, relevant marketing, training and events based on the users’ communication preferences.
### Billing Information
In order to purchase a subscription to our Platform, you will be required to provide certain additional information which may include a credit card number, expiration date, billing zip code, activation code, and similar information (“Billing Information”).
Billing Information is collected and processed by our third-party payment processor operating as our agent. Holistics does not directly store, obtain or process any Billing Information within our own application.
### Information Tracking Technologies
We receive and store certain information about how you use our websites and Services when you visit them through the use of Information Tracking Technologies (“ITT”), which include first-party and third-party cookies, logs, web beacons, and other similar technologies. Our servers collect similar information when you are logged into our website or Services.
The information we receive through ITT may be associated with you, depending on the website or Services you are using, and whether you have provided information identifying yourself to the website or Services.
Cookies are identifiers we transfer to your browser or device that allow us to recognize your browser or device and tell us how and when pages and features in our Services are visited and by how many people.
For example, we receive information that your browser or device sends to our servers whenever you visit a Holistics website. Your browser or device may tell us your internet protocol (IP) address used to connect your computer to the Internet, computer and connection information such as browser type, version, language and time zone settings, browser plug-in types, operating system, and type of device you are using.
When you visit our Site, your browser may also tell us information such as the actions you take on our Site, the page that led you to our Site and, if applicable, the search terms you typed into a search engine that led you to our Site.
You may be able to change the preferences on your browser or device to prevent or limit your device’s acceptance of cookies, but this may prevent you from taking advantage of some of our features. We may use this data to customize content for you that we think you might like, based on your usage patterns, and generally to improve the Services.
We use ITT to collect information about your interactions with the Site (“Usage Data”) and how the Site is performing (“Analytics Data”). Usage Data may include information regarding any interaction you have with the Site, such as which functionalities are used and the frequency of use (e.g., pages visited, actions taken, queries run, user accounts, account roles, and connected database types). Analytics Data may include query response times, application response times and other metrics that monitor the responsiveness of the Site.
In addition, third parties may be able to collect information about your online activities when you use our websites or Services from ITT. We do not respond to web browser ‘do not track’ signals or other similar transmissions that indicate a request to disable online tracking of users who visit our websites or who use our websites or Services.
If you receive emails from us, we may use certain analytics tools, to capture data such as when (and where) you opened our email or click on any links or banners our email contains. This data helps us to gauge the effectiveness of our communications and marketing campaigns.
### Trial Sign-Ups
When you sign up for a trial, we collect your work email and rejects the use of common personal publicly available email domain accounts. We will send product, marketing and sales related emails designed to help facilitate your product evaluation.
Holistics use third party tools to help you onboard effectively, such as setting up user onboarding flows, automate pop-up message, or to study in-app activity of trial users to identify and clear obstacles for them to have a successful onboarding experience.
### Information from Support and Success Channels
Information you provide through our support and success channels will be stored. The Services also include our customer support and customer success, where you may choose to submit information regarding a problem you are experiencing with a Service.
Whether you designate yourself as a technical contact, open a support ticket, speak to one of our representatives directly or otherwise engage with our support team, you will be asked to provide contact information, a summary of the problem you are experiencing, and any other documentation, screenshots or information that would be helpful in resolving the issue.
From time to time, our support team may temporarily access your tenant for the sole purpose of prompt resolution and fast troubleshooting. All such access attempts are logged internally and associated with a support ticket.
Information sent to our group emails for support and customer success will be accessible by our engineering and business teams on duty to provide you a more holistic and responsive service level. If any of such data needs to be shared, please indicate in your email communications that such data is sensitive and confidential.
For the avoidance of doubt, emails sent with generic company confidentiality boilerplates footers at all emails appended by default will not be classified as such sensitive/confidential information unless otherwise stated.
### Information Collected from Third Parties
We maintain pages on online social networks and advertising sites. We may collect information when you interact with our social network pages.
We advertise online, including displaying Holistics ads across the Internet on websites and in apps. When we advertise online to you, we may collect information about which ads are displayed to you, which ads you click on, and the web page where the ad was displayed to you.
## Information Stored on Holistics
We act as both a Controller and Processor of Information Stored for our customers.
### As a Controller of Personal Information
We store data on Holistics customers and visitors on our Site and Service. This includes details such as trial sign-up information, login details (encrypted), as well as metadata about product usage. Metadata is used to facilitate product improvements, customer support and license auditing.
### As a Processor of Personal Information
Where our Customer Database (described in our terms of service) is connected to Holistics, and the Customer Database contains Personal Information necessary to answer queries from Holistics users, Holisics acts as a processor of Personal Information.
Holistics does not store or sync a copy of your data directly from source, so your data remains stored on your own central servers.
You can provide read-only connection to access the minimum amount of data needed to answer your questions in your query results. You may choose to provide Holistics write-access to your database for our ETL features, where Holistics will help you move data across your data sources, or run in-database transformation to speed up query performance.
You may also leverage on the Holistics cache, which speeds up your dashboard/report access time by preloading your query results at scheduled intervals of your choosing. This cache is set to automatically expire at your preferred duration, and can be turned off on a per report basis. You can choose to remove the cache data at any time for your query.
## How We Use Personal Information
In relation to any Personal Information obtained from Google, please refer to the designated [Google User Data](#google-user-data) section outlined below. For all other scenarios, Holistics may employ your Personal Information in order to:
* Provide you with Holistics website content, the features, functions and benefits of the Service.
* Respond to your requests for information, products, or services, and to provide customer service and support.
* Operate, maintain and improve our websites and Services (such as, for the purposes of fixing malfunctions, testing our security systems, etc.)
* Provide you with notices related to your use of the Service.
* Personalize our website, Services, and Communications to your likely interests and
needs
* Send you business messages such as those related to Services notifications,
payments or renewal of your subscription, or process billing for your Service subscription (i.e. charging your credit card).
* Provide you with promotional and marketing emails. You can opt-out of receiving certain types of promotional and marketing emails, but if you do you may not receive the full benefit of the Service. Opting-out can be done by following the instructions at the bottom of the promotional material.
* Contact you via telephone to discuss our Services and related offers with you
* Personalize the Service experience for you (such as, remembering your information
so you will not have to enter it each time you use the Service).
* Enhance, improve and further develop the Service (such as, creating new features or
functions, refining the user experience, increasing Service technical performance,
etc.).
* Email you periodically (intervals of months) to to explore if you like to re-evaluate our Services if you have previously tried us.
* Enable advertising delivered to you to be more relevant.
* For other purposes about which we notify you.
From time to time, we may provide information to our customers and potential customers in the form of electronic or print newsletters. When you subscribe to our newsletter you may be added to our mailing list and will receive announcements and information about Holistics. It will be emailed or mailed to the address that you provide when you subscribe.
Holistics’ third-party service providers serve ads on our behalf across the Internet. Some of these ads may be personalized for you based on information collected from your use of the Site.
### Google User Data
* Holistics restricts its data access exclusively to the files you supply within the application configuration. A cached version of these files may be retained. We refrain from downloading or storing any files from your Google account that were not expressly provided within the application.
* Holistics accesses and reads data from files (including CSV, Sheet, and Excel formats) on your Google Drive, as an integral component of our Data Import feature. Subsequently, we process this data and import it into the data warehouse you have configured. Furthermore, we may generate new files or alter existing ones for the purpose of exporting data via our Scheduled Deliveries feature, which transfers data to Google Sheets.
* We do not share or sell any of your files with any third parties.
* We reserve the right to review the data you provide solely as required for the upkeep, provision, and enhancement of the Service, or to address a support request initiated by you. Additionally, we may examine this data in the aggregate and on an anonymous basis to gain insights into the usage patterns of Holistics.
## Personal Information Sharing
In certain circumstances, Holistics may share your Personal Information with third-party service providers that provide the below services for Holisics:
* Email communications (operational, marketing).
* Customer Relationship Management.
* Data management.
* Database hosting.
* Payment card processing.
* Helpdesk services.
* Shipping services.
* Collaboration services.
* Cloud services.
* Online Advertisements. For Google User data, we do not transfer or disclose or sell your information to third parties services providers that provide Online Advertisements service.
We will only pass your data on to third parties without your express consent if we are obliged to do so by statutory law or an instruction by a public authority or court.
## Information security and storage
We understand that the security of your Personal Information is important. We implement reasonable administrative, technical, and physical security controls designed to protect your Personal Information from loss, misuse, unauthorized access, disclosure, alteration, or destruction. However, despite our efforts, no security controls are completely effective and we cannot ensure or warrant the security of your Personal Information.
Your Personal Information and data files are stored on our servers and the servers of our affiliated companies and companies we hire to provide services to us.
In respect to any data originating from Google's platforms that Holistics may access, utilize, or retain, please be advised that we implement suitable security measures commensurate with the nature of said data. Such protections include, but are not limited to, cryptographic encryption and other procedural safeguards designed to preserve the confidentiality and integrity of your information.
## Cross-border data transfers
Your Personal Information may be stored in Singapore, the United States, where our employees reside, or transferred to other countries where the companies we hire to help us run our business are located. Those countries may not have the same data protection laws as the country in which you initially provided the information. When we transfer your Personal Information, we will protect it as described in this privacy policy.
## How Long Do We Retain Your Personal Information?
We will retain personal information for as long as it is needed for legitimate business purposes to fulfil the purposes we collected it for, including for the purposes of satisfying any legal, accounting, or reporting requirements. We keep such servers to help protect the stability and availability of the Service (such as protecting it from viruses and malfunctions).
To determine the appropriate retention period for personal information, we consider the amount, nature, and sensitivity of the personal information, the potential risk of harm from unauthorised use or disclosure of your personal information, the purposes for which we process your personal information and whether we can achieve those purposes through other means, and the applicable legal requirements.
Some of your Personal Information may also remain on backup systems or third-party services after your use of our websites and/or Services ends, unless you request that your data be deleted.
Details of retention periods for different aspects of your personal information are available in our retention policy which you can request from us by contacting us. See the **“Reviewing, updating or deleting your Personal Information”** section below.
## Reviewing, updating or deleting your Personal Information
We take reasonable steps intended to ensure that your Personal Information we collect is accurate, complete, and current by using the most recent information provided to us.
Our websites and Services may allow you to review and edit your Personal Information by accessing your profile or similar feature of the website or Service you are using.
For our websites, you may have the ability to manage your cookies and similar technologies through your web browser settings. You should consult the settings and instructions provided by the provider of your web browser for more information.
You may also submit a request to us to review, edit, or delete your Personal Information by emailing your request to privacy@holistics.io
Our business hours for telephone contact are 9:00 AM to 6:00 PM Singapore Time (GMT+8). Once we verify your identify, we will assist you with your request.
You can choose not to provide us with your Personal Information, but if you do not provide us with your Personal Information when we request it, we may not be able to provide you with the websites and Services you use, or tailor them to you.
### Opting-out of Holistics Communications
We may occasionally send you notification emails about updates to our product, legal documents, offer customer support or marketing emails. Except for cases where we are required to do so by law (e.g. notifying you of a data breach), you shall have the opportunity to unsubscribe from receiving these messages free of charge.
You may opt out of receiving Communications by modifying your website or Service profile, or by unsubscribing to the marketing mailings or newsletters you no longer desire. To unsubscribe, please follow the "Unsubscribe" instructions that are contained within the mailing, newsletter or other Communication that we send to you.
You may also send an email to privacy@holistics.io with "Unsubscribe" in the body, together with a description of the Communications you no longer desire to receive.
### Minimum Age
The Site is not directed to, nor intended to be used by, individuals under the age of 13. Holistics does not knowingly collect personal information from individuals under the age of 13. If you become aware that an individual under the age of 13 has provided us with personal information, please contact us immediately at privacy@holistics.io. If we become aware that an individual under the age of 13 has provided us with personal information, we will take steps to delete such information.
### For More Information
If you have any questions or concerns about this Privacy Policy, please contact us at privacy@holistics.io or 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545.
---
## Terms of service (effective 2 June 2020)
:::warning Superseded version
This is the **2 June 2020** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Terms of service](/legal/terms).
:::
:::tip Note
To download our Terms of Service, visit: https://r.holistics.io/terms. Click on **File >** download **Download As** then select your preferred format.
:::
_Updated: 2 June 2020_
## 1. Definitions
1. "Holistics" refers to Holistics Software Pte Ltd, a private company limited by shares in Singapore.
2. "Subscription Service" or "Service" refers to the cloud business intelligence software, professional services and technical support services provided by Holistics at the website (https://www.holistics.io/), consisting of
1. all proprietary technology (software, hardware, processes, algorithms, user interfaces, know-how, techniques, templates, designs and other tangible or intangible technical material or information) of Holistics,
2. licensors and service providers used by Holistics to provide the Subscription Services;
3. Holistics Virtual Analyst Professional Services and Customer Support
3. "Order Form" refers to the purchase confirmation of Holistics Service from Customer,from a document identifying the Subscription Service, Pricing Metrics and Price signed by a duly authorized representative of the Customer and made available by Holistics pursuant to this TOS.
4. "Pricing Metrics" refers to the value metrics that Holistics charges for the users and usage of the Subscription Service described in the Order Form or Pricing Page
5. "Party" refers to the business entity of either Holistics or the Customer.
6. "Customer Database" refers to the business data records stored in the customer database that is successfully connected to Holistics software, which will be classified as confidential information.
## 2. General Terms of Service
1. This Terms of Service ("TOS") constitute a single, legally binding document, which govern the Customer’s use of the Service by the law of Singapore, even though it is electronic and is not physically signed by Customer and Holistics.
2. Customer warrants that it is a legal entity in good standing in the jurisdiction of its formation.
3. By accepting this TOS or by continuing to access or use the Service, Customer acknowledge that Customer have read, understood, and agree to be bound by this TOS.
4. The failure of Holistics to exercise or enforce any right or provision of this TOS shall not be a waiver of that right.
5. From time to time, Holistics may modify this TOS without prior notice, and the TOS becomes effective from the date of posting. Customer can review the most current version of this TOS at any time at [https://docs.holistics.io/docs/terms-of-service](/legal/archive/terms-of-service/2020-06-02).
## 3. Subscription Service
1. Holistics own all right, title and interest in and to the Subscription Services, including all related intellectual property rights. Holistics reserves all rights not expressly granted to Customer under this TOS.
2. Subject to the TOS, Holistics grant Customer a limited, worldwide, non-exclusive, non-transferable right to use the paid Subscription Service solely in connection with the Customer’s internal business operations.
3. Customer’s use of the Subscription Service includes the right to access all functionality available in the purchased Subscription Service as of the date specified in the Order Form, subjected to the applicable pricing metrics based on users and software feature usage described on the Order Form.
4. If Customer uses the Service in excess of the specified Pricing Metrics in the Order Form, Customer agrees to report such additional usage to Holistics immediately. The increase will be documented in an amended signed Order Form and billing for additional Pricing Metrics will be payable at rates consistent with those in the Order Form (unless the parties agree to other rates) for the then current Subscription Term according to Section 4 (Billing and Fees).
5. Holistics may monitor Customer’s use in order to verify that Customer has not exceeded its permitted Pricing Metrics. If Holistics becomes aware of any such excess usage, then Customer will pay for the excess usage and for any ongoing excess usage under the same terms as in subsection (4) above.
6. Customer may access and use the Service only for lawful purposes and shall not mimic its functionality by creating derivative works based on the Service’s features, user interface or functionality pattern
7. Holistics reserves the right to change the price, pricing metric, pricing metric definitions, service offering or add-ons at any time by giving written notice to the customer at least thirty (30) days in advance.
## 4. Billing and Fees
1. Customer agree to pay Holistics the amount that is specified in the Order Form within the use of the applicable Pricing Metrics. Unless otherwise stated, all fees are non-cancelable and non-refundable.
2. Customer’s subscription term shall be automatically renewed for a period equal to the initial term unless either Party notifies the other in writing at least 30 days prior to expiration of the then-current term.
3. Pricing in USD for reference purpose only. Final transaction charge will be in Singapore Dollars based on a reasonable exchange rate adjustment.
4. Customer is solely responsible for the payment of all taxes, assessments, tariffs, duties or other fees imposed, assessed or collected by or under the authority of any governmental body (collectively, "Taxes").
5. If Customer's account is thirty (30) days or more overdue, in addition to any of its other rights or remedies (including but not limited to any termination rights set forth herein), Holistics reserves the right to suspend Customer’s access to the Service without liability to Customer until such amounts are paid in full.
6. Customer will notify Holistics within sixty (60) days from the invoice date if there’s a dispute on any charges.
Holistics reserve the right to revise prices by providing written notice to the Customer at least 30 days before the change is to take effect.
## 5. Credit Card Payments
1. Credit Card payment mode is the default payment mode for customers not on the Enterprise Pricing Plan.
2. Customer must be authorized to use the credit card information submitted to create the billing account.
3. Holistics does not store credit card information on our servers. Billing shall be done via third-party authorized billing agents, utilizing industry-standard security software in order to create a safe transaction.
4. Customer hereby authorize Holistics to bill Customer payment instrument in advance on a periodic basis until Customer terminate Customer account, and Customer further agree to pay any charges so incurred.
5. Holistics shall provide an invoice for each transaction made to the Customer credit card.
## 6. Invoice Payment Methods
1. Invoice Payment Modes will only be applicable for Customers
1. On the Enterprise Subscription plan
2. Billed on an annual subscription
3. Customer understands that an admin charge (specified in the Order Form) will apply for each invoice sent to Customers not on (a).
4. All Subscription Services fees will be invoiced in advance as set forth in the applicable Order Form.
5. Except as otherwise set forth in the applicable Order Form, Customer agrees to pay all invoiced amounts within the stipulated payment terms period on the invoice.
6. For international telegraphic transfers, Customer and Holistics will be responsible for the transaction fees for each other’s own respective banks or financial institution.
7. Late payments made to the service are subject to a penalty of an extra 1.5% of the subscription price as per the latest currency exchange rate from the date of default by the user, per month of active subscription.
8. If any amounts owed by Customer for the Service are overdue for at least thirty (30) days, Holistics may, without limiting Holistics’ other rights and remedies, suspend Customer’s access to the Services until such amounts are paid in full.
## 7. Customer Data, Feedback and Metadata
1. Holistics does not own and shall not be responsible for any data, information or material authorized by Customer to retrieve or submit to Holistics in the course of using the Service ("Customer Data").
2. Customer shall be solely responsible for the accuracy, quality, integrity, legality, reliability, appropriateness, and intellectual property ownership or right to use all Customer Data, and Holistics shall not be responsible or liable for the deletion, correction, destruction, damage, loss or failure to store any Customer Data.
3. Customer may provide Holistics with feedback, suggestions, and ideas, if Customer chooses, about the Service ("Feedback").
4. Customer agrees that the Feedback submitted to Holistics
1. Does not contain confidential or proprietary information and does not bind Holistics to any obligation of confidentiality, express or implied
2. May be used by Holistics to reproduce, modify, create derivative works from, distribute, or disclose (or choose not to use or disclose) such Feedback for any purpose, in any way, in any media worldwide without any obligation to provide attribution or compensation to Customer or any third party.;
5. Holistics may monitor Customer’s use of the Services and use data related to Customer’s use in an aggregate and anonymous manner ("Metadata"), including to compile statistical and performance information related to the provision and operation of the Services. Holistics retains all intellectual property rights in such Metadata.
6. Customer agrees that Holistics may make the Metadata publicly available, provided that such information does not incorporate any Customer Data and/or identify Customer or its Confidential Information.
## 8. Customer Support
1. Customer can email Holistics Customer Support for software related support, or to request for assistance, guidance and advice on the use of the software.
2. Holistics support team is open from 9am to 6pm Singapore Time (UTC+8) from Mondays to Fridays, excluding Singapore’s Public holidays.
3. Holistics reserves the right to temporarily access Customer’s user account(s) to identify and resolve potential root-causes and problems raised.
## 9. Account Information from Third Party Providers
1. Customer may direct Holistics to retrieve certain information maintained online by third party providers that has a customer-vendor relationship with the Customer.
2. Holistics may require the Customer to provide the login information necessary to access Customer account with third party providers that the Customer have a customer relationship with.
3. By using the Service and providing Customer Access Information, Customer expressly authorize Holistics to access and use Customer Account Information maintained by identified third parties, on Customer behalf as Customer agent.
4. Customer represent and warrant that neither the foregoing (or anything else in this TOS) nor Customer use of the Services will violate any agreement or terms to which Customer are subject, including without limitation, those with respect to any third party site.
5. Customer acknowledge and agree that when Holistics accesses and retrieves account information from third party sites, holistics is acting as customer agent, and not as the agent of or on behalf of the third party.
6. As such, Holistics is not liable for any damage or loss caused or alleged to be caused by or in connection with use of or reliance on any such third party services.
7. Holistics does not guarantee that any such third party services will continue to be made available within the Service, and such services may be removed or disabled by Holistics at any time without notice to you. You acknowledge and agree that the Service may not be sponsored or endorsed by the third party services accessible through the Service.
## 10. Free Services
1. "Free Services" refers to the use of Holistics Subscription Service on an unpaid trial or free basis.
2. Holistics has the right to suspend, limit or terminate the Free Services for any reason at any time without notice. Holistics may terminate subscriptions to the Free Services due to inactivity. Unless Customer purchases a subscription for Holistics Service, upon any such termination or expiration Customer Free Services will cease and Customer will no longer have access to any Customer Data used in connection with the Free Services.
3. Customer access to the Free Services is limited to evaluating whether to purchase a subscription for Holistics' Service. Customer may not use the Free Services for any other purposes, including but not limited to competitive analysis, commercial, professional or for-profit purposes.
## 11. Termination
1. Either party may terminate this TOS (including all related Order Forms) if the other party:
1. fails to cure any material breach of this TOS within thirty (30) days after written notice of such breach;
2. ceases operation without a successor; or
3. seeks protection under any bankruptcy, receivership, trust deed, creditors arrangement, composition or comparable proceeding, or if any such proceeding is instituted against such party (and not dismissed within sixty (60) days)).
2. Termination is not an exclusive remedy and the exercise by either party of any remedy under this TOS will be without prejudice to any other remedies it may have under this TOS, by law, or otherwise.
3. Customer is responsible for disconnecting all data sources connected to Holistics upon termination of the service. Holistics will not be liable for any security breach of any data leaked if Customer terminated this subscription without disconnecting their data source.
4. All Customer Data on the Service (if any) may be permanently deleted by Holistics upon termination.
5. Holistics shall not be liable to Customer or any third party for any modification, suspension or discontinuation of the Service. All accrued rights to billing and payment shall survive termination of this TOS.
6. Customer may not assign this TOS without the prior written consent of Holistics, but Holistics may assign or transfer this TOS, in whole or in part, without restriction by providing 30 days written notice in advance.
7. Customer understands that any dispute arising out of or in connection with this TOS, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause. The seat of the arbitration shall be in Singapore, and the tribunal shall consist of one (1) arbitrator.
## 12. Indemnification
1. Customer, not Holistics, shall be solely responsibility for the accuracy, quality, integrity, legality, reliability, appropriateness, and intellectual property ownership or right to use all Customer Data, and Holistics shall not be responsible or liable for the deletion, correction, destruction, damage, loss or failure to store any Customer Data.
2. Customer will defend, indemnify and hold harmless Holistics, its suppliers and licensors, and its respective subsidiaries, affiliates, officers, agents, employees, representatives, and assigns, from any costs, damages, expenses, and liability caused by Customer’s use of the Service, Customer’s violation of this TOS, or Customer’s violation of any rights of a third party through use of the Service.
3. Customer agree to indemnify and hold Holistics harmless from and against any loss, cost, damage and expense, including but not limited to attorney’s fees and court costs, arising directly or indirectly from use of the Holistics Service and/or Customer’s breach of any representation, warranty or restriction contained in this TOS.
## 13. Limitation of Liability
1. Neither party shall be liable under this TOS for any of the following losses suffered or incurred by the other party (whether or not such losses were within the contemplation of the parties at the date of this TOS):
1. loss of actual or anticipated profits (including loss of profits on contracts);
2. loss of anticipated savings;
3. loss of business opportunity;
4. loss of reputation or damage to goodwill; and
5. special, indirect or consequential losses.
2. Each party's liability under this TOS in relation to liability arising from any given event or series of connected events shall be limited to the total amount paid by Customer in the twelve (12) months immediately preceding the month in which the event (or first in a series of connected events) occurred.
---
## Terms of service (effective 20 March 2023)
:::warning Superseded version
This is the **20 March 2023** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Terms of service](/legal/terms).
:::
:::tip Note
To download our Terms of Service, visit: https://go.holistics.io/terms. Click on **File >** download **Download As** then select your preferred format.
:::
_Last Updated: 20 March 2023 (from 02 June 2020)_
- Section 1 (Definitions): Included dbdiagram and dbdocs into definition of subscription service.
- Section 6 (Invoiced Payment Method): Removed Section 6(7) as it is already addressed in Section 4(5).
## 1. Definitions
1. "Holistics" refers to Holistics Software Pte Ltd, a private company limited by shares in Singapore.
2. "Subscription Service" or "Service" refers to the software, professional services and technical support services consisting of either (or all of)
1. Holistics Software
* Holistics Cloud Business Intelligence (www.holistics.io)
* dbdiagram (https://dbdiagram.io/)
* dbdocs (https://dbdocs.io/)
2. All proprietary technology (software, hardware, processes, algorithms, user interfaces, know-how, techniques, templates, designs and other tangible or intangible technical material or information) of Holistics
3. Licensors and service providers used by Holistics to provide the Subscription Services;
4. Holistics Virtual Analyst Professional Services and Customer Support
3. "Order Form" refers to the purchase confirmation of Holistics Service from Customer,from a document identifying the Subscription Service, Pricing Metrics and Price signed by a duly authorized representative of the Customer and made available by Holistics pursuant to this TOS.
4. "Pricing Metrics" refers to the value metrics that Holistics charges for the users and usage of the Subscription Service described in the Order Form or Pricing Page
5. "Party" refers to the business entity of either Holistics or the Customer.
6. "Customer Database" refers to the business data records stored in the customer database that is successfully connected to Holistics software, which will be classified as confidential information.
## 2. General Terms of Service
1. This Terms of Service ("TOS") constitute a single, legally binding document, which govern the Customer’s use of the Service by the law of Singapore, even though it is electronic and is not physically signed by Customer and Holistics.
2. Customer warrants that it is a legal entity in good standing in the jurisdiction of its formation.
3. By accepting this TOS or by continuing to access or use the Service, Customer acknowledge that Customer have read, understood, and agree to be bound by this TOS.
4. The failure of Holistics to exercise or enforce any right or provision of this TOS shall not be a waiver of that right.
5. From time to time, Holistics may modify this TOS without prior notice, and the TOS becomes effective from the date of posting. Customer can review the most current version of this TOS at any time at [https://docs.holistics.io/docs/terms-of-service](/legal/archive/terms-of-service/2023-03-20).
## 3. Subscription Service
1. Holistics own all right, title and interest in and to the Subscription Services, including all related intellectual property rights. Holistics reserves all rights not expressly granted to Customer under this TOS.
2. Subject to the TOS, Holistics grant Customer a limited, worldwide, non-exclusive, non-transferable right to use the paid Subscription Service solely in connection with the Customer’s internal business operations.
3. Customer’s use of the Subscription Service includes the right to access all functionality available in the purchased Subscription Service as of the date specified in the Order Form, subjected to the applicable pricing metrics based on users and software feature usage described on the Order Form.
4. If Customer uses the Service in excess of the specified Pricing Metrics in the Order Form, Customer agrees to report such additional usage to Holistics immediately. The increase will be documented in an amended signed Order Form and billing for additional Pricing Metrics will be payable at rates consistent with those in the Order Form (unless the parties agree to other rates) for the then current Subscription Term according to Section 4 (Billing and Fees).
5. Holistics may monitor Customer’s use in order to verify that Customer has not exceeded its permitted Pricing Metrics. If Holistics becomes aware of any such excess usage, then Customer will pay for the excess usage and for any ongoing excess usage under the same terms as in subsection (4) above.
6. Customer may access and use the Service only for lawful purposes and shall not mimic its functionality by creating derivative works based on the Service’s features, user interface or functionality pattern
7. Holistics reserves the right to change the price, pricing metric, pricing metric definitions, service offering or add-ons at any time by giving written notice to the customer at least thirty (30) days in advance.
## 4. Billing and Fees
1. Customer agree to pay Holistics the amount that is specified in the Order Form within the use of the applicable Pricing Metrics. Unless otherwise stated, all fees are non-cancelable and non-refundable.
2. Customer’s subscription term shall be automatically renewed for a period equal to the initial term unless either Party notifies the other in writing at least 30 days prior to expiration of the then-current term.
3. Pricing in USD for reference purpose only. Final transaction charge will be in Singapore Dollars based on a reasonable exchange rate adjustment.
4. Customer is solely responsible for the payment of all taxes, assessments, tariffs, duties or other fees imposed, assessed or collected by or under the authority of any governmental body (collectively, "Taxes").
5. If Customer's account is thirty (30) days or more overdue, in addition to any of its other rights or remedies (including but not limited to any termination rights set forth herein), Holistics reserves the right to suspend Customer’s access to the Service without liability to Customer until such amounts are paid in full.
6. Customer will notify Holistics within sixty (60) days from the invoice date if there’s a dispute on any charges.
Holistics reserve the right to revise prices by providing written notice to the Customer at least 30 days before the change is to take effect.
## 5. Credit Card Payments
1. Credit Card payment mode is the default payment mode for customers not on the Enterprise Pricing Plan.
2. Customer must be authorized to use the credit card information submitted to create the billing account.
3. Holistics does not store credit card information on our servers. Billing shall be done via third-party authorized billing agents, utilizing industry-standard security software in order to create a safe transaction.
4. Customer hereby authorize Holistics to bill Customer payment instrument in advance on a periodic basis until Customer terminate Customer account, and Customer further agree to pay any charges so incurred.
5. Holistics shall provide an invoice for each transaction made to the Customer credit card.
## 6. Invoice Payment Methods
1. Invoice Payment Modes will only be applicable for Customers
1. On the Enterprise Subscription plan
2. Billed on an annual subscription
2. Customer understands that an admin charge (specified in the Order Form) will apply for each invoice sent to Customers not on (1).
3. All Subscription Services fees will be invoiced in advance as set forth in the applicable Order Form.
4. Except as otherwise set forth in the applicable Order Form, Customer agrees to pay all invoiced amounts within the stipulated payment terms period on the invoice.
5. For international telegraphic transfers, Customer and Holistics will be responsible for the transaction fees for each other’s own respective banks or financial institution.
6. Late payments made to the service are subject to a penalty of an extra 1.5% of the subscription price as per the latest currency exchange rate from the date of default by the user, per month of active subscription.
## 7. Customer Data, Feedback and Metadata
1. Holistics does not own and shall not be responsible for any data, information or material authorized by Customer to retrieve or submit to Holistics in the course of using the Service ("Customer Data").
2. Customer shall be solely responsible for the accuracy, quality, integrity, legality, reliability, appropriateness, and intellectual property ownership or right to use all Customer Data, and Holistics shall not be responsible or liable for the deletion, correction, destruction, damage, loss or failure to store any Customer Data.
3. Customer may provide Holistics with feedback, suggestions, and ideas, if Customer chooses, about the Service ("Feedback").
4. Customer agrees that the Feedback submitted to Holistics
1. Does not contain confidential or proprietary information and does not bind Holistics to any obligation of confidentiality, express or implied
2. May be used by Holistics to reproduce, modify, create derivative works from, distribute, or disclose (or choose not to use or disclose) such Feedback for any purpose, in any way, in any media worldwide without any obligation to provide attribution or compensation to Customer or any third party.;
5. Holistics may monitor Customer’s use of the Services and use data related to Customer’s use in an aggregate and anonymous manner ("Metadata"), including to compile statistical and performance information related to the provision and operation of the Services. Holistics retains all intellectual property rights in such Metadata.
6. Customer agrees that Holistics may make the Metadata publicly available, provided that such information does not incorporate any Customer Data and/or identify Customer or its Confidential Information.
## 8. Customer Support
1. Customer can email Holistics Customer Support for software related support, or to request for assistance, guidance and advice on the use of the software.
2. Holistics support team is open from 9am to 6pm Singapore Time (UTC+8) from Mondays to Fridays, excluding Singapore’s Public holidays.
3. Holistics reserves the right to temporarily access Customer’s user account(s) to identify and resolve potential root-causes and problems raised.
## 9. Account Information from Third Party Providers
1. Customer may direct Holistics to retrieve certain information maintained online by third party providers that has a customer-vendor relationship with the Customer.
2. Holistics may require the Customer to provide the login information necessary to access Customer account with third party providers that the Customer have a customer relationship with.
3. By using the Service and providing Customer Access Information, Customer expressly authorize Holistics to access and use Customer Account Information maintained by identified third parties, on Customer behalf as Customer agent.
4. Customer represents and warrants that neither the foregoing (or anything else in this TOS) nor Customer use of the Services will violate any agreement or terms to which Customer are subject, including without limitation, those with respect to any third party site.
5. Customer acknowledges and agrees that when Holistics accesses and retrieves account information from third party sites, holistics is acting as customer agent, and not as the agent of or on behalf of the third party.
6. As such, Holistics is not liable for any damage or loss caused or alleged to be caused by or in connection with use of or reliance on any such third party services.
7. Holistics does not guarantee that any such third party services will continue to be made available within the Service, and such services may be removed or disabled by Holistics at any time without notice to you. You acknowledge and agree that the Service may not be sponsored or endorsed by the third party services accessible through the Service.
## 10. Free Services
1. "Free Services" refers to the use of Holistics Subscription Service on an unpaid trial or free basis.
2. Holistics has the right to suspend, limit or terminate the Free Services for any reason at any time without notice. Holistics may terminate subscriptions to the Free Services due to inactivity. Unless Customer purchases a subscription for Holistics Service, upon any such termination or expiration Customer Free Services will cease and Customer will no longer have access to any Customer Data used in connection with the Free Services.
3. Customer access to the Free Services is limited to evaluating whether to purchase a subscription for Holistics' Service. Customer may not use the Free Services for any other purposes, including but not limited to competitive analysis, commercial, professional or for-profit purposes.
## 11. Termination
1. Either party may terminate this TOS (including all related Order Forms) if the other party:
1. fails to cure any material breach of this TOS within thirty (30) days after written notice of such breach;
2. ceases operation without a successor; or
3. seeks protection under any bankruptcy, receivership, trust deed, creditors arrangement, composition or comparable proceeding, or if any such proceeding is instituted against such party (and not dismissed within sixty (60) days)).
2. Termination is not an exclusive remedy and the exercise by either party of any remedy under this TOS will be without prejudice to any other remedies it may have under this TOS, by law, or otherwise.
3. Customer is responsible for disconnecting all data sources connected to Holistics upon termination of the service. Holistics will not be liable for any security breach of any data leaked if Customer terminated this subscription without disconnecting their data source.
4. All Customer Data on the Service (if any) may be permanently deleted by Holistics upon termination.
5. Holistics shall not be liable to Customer or any third party for any modification, suspension or discontinuation of the Service. All accrued rights to billing and payment shall survive termination of this TOS.
6. Customer may not assign this TOS without the prior written consent of Holistics, but Holistics may assign or transfer this TOS, in whole or in part, without restriction by providing 30 days written notice in advance.
7. Customer understands that any dispute arising out of or in connection with this TOS, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause. The seat of the arbitration shall be in Singapore, and the tribunal shall consist of one (1) arbitrator.
## 12. Indemnification
1. Customer, not Holistics, shall be solely responsibility for the accuracy, quality, integrity, legality, reliability, appropriateness, and intellectual property ownership or right to use all Customer Data, and Holistics shall not be responsible or liable for the deletion, correction, destruction, damage, loss or failure to store any Customer Data.
2. Customer will defend, indemnify and hold harmless Holistics, its suppliers and licensors, and its respective subsidiaries, affiliates, officers, agents, employees, representatives, and assigns, from any costs, damages, expenses, and liability caused by Customer’s use of the Service, Customer’s violation of this TOS, or Customer’s violation of any rights of a third party through use of the Service.
3. Customer agrees to indemnify and hold Holistics harmless from and against any loss, cost, damage and expense, including but not limited to attorney’s fees and court costs, arising directly or indirectly from use of the Holistics Service and/or Customer’s breach of any representation, warranty or restriction contained in this TOS.
## 13. Limitation of Liability
1. Neither party shall be liable under this TOS for any of the following losses suffered or incurred by the other party (whether or not such losses were within the contemplation of the parties at the date of this TOS):
1. loss of actual or anticipated profits (including loss of profits on contracts);
2. loss of anticipated savings;
3. loss of business opportunity;
4. loss of reputation or damage to goodwill; and
5. special, indirect or consequential losses.
2. Each party's liability under this TOS in relation to liability arising from any given event or series of connected events shall be limited to the total amount paid by Customer in the twelve (12) months immediately preceding the month in which the event (or first in a series of connected events) occurred.
---
## Terms of service (effective 24 May 2024)
:::warning Superseded version
This is the **24 May 2024** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Terms of service](/legal/terms).
:::
:::tip Note
To download our Terms of Service, visit: https://go.holistics.io/terms. Click on **File >** download **Download As** then select your preferred format.
:::
_Last Updated: 24 May 2024 (from 20 March 2023)_
## 1. Definitions
**"Affiliate"** means any entity, whether now in existence or subsequently created, which directly or indirectly controls, is controlled by, or is under common control with a party to this Agreement. Control for the purposes of this definition is evidenced by direct or indirect ownership or control of more than 50% of the voting interests of the subject entity. Examples of such relationships include, but are not limited to, subsidiaries (entities controlled by a party), parents (entities that control a party), and siblings (entities under common control with a party).
**“Customer”, “you”, or “your”** refers to the person or entity using the Subscription Service and identified in the applicable account record, billing statement, online subscription process, or Subscription Agreement as the customer and your Affiliates in the scope of your purchase.
**“Customer Data”** refers to data that Holistics stores, processes, and/or secures for each Individual Product subscribed by the Customer as defined in the Individual Product Terms.
**"Embedded Solution"** refers to the incorporation, integration, or inclusion of any Holistics service or functionality from any Individual Product within a customer’s or third party's application, interface, or system in such a manner that it appears part of or operates seamlessly as part of the customer’s or third party's product offering.
**"Holistics", “we”, "us", or “our”** refers to Holistics Software Pte Ltd, a corporation incorporated under Singapore law, designated as a private company limited by shares.
**“Individual Product”** refers to one of the below software developed by Holistics.
- **“Holistics BI”** refers to the business intelligence software from Holistics (www.holistics.io).
- **“Holistics dbdiagram”** refers to the database diagramming software from Holistics (www.dbdiagram.io).
- **“Holistics dbdocs”** refers to the database documentation software from Holistics (www.dbdocs.io)
- **“Individual Product Terms”** refers to the terms for any one of the specific mentioned software in Holistics described in this TOS.
**"Party"** represents either Holistics or the Customer as a business entity.
**"Subscription Service" or "Service"** includes the software, professional services, and technical support services provided by Holistics listed under the Individual Product. This includes any updates, enhancements, new features, documentation, and educational content provided or made available to the Customer.
**"Subscription Agreement" or “Order Form”** means any agreement confirming the purchase of the Holistics Service, whether executed through in-app self-service processes, or via electronic signature of a Holistics Order Form. It becomes legally binding upon digital or electronic signing by an authorized representative of the Customer or by the completion of the subscription process within the Holistics application, each with the same legal force as a handwritten signature.
**“User” or "Users"** means employees, representatives, consultants, contractors, or agents authorized by the Customer to use the Subscription Service and have unique user identifications and passwords.
## 2. General Terms
**2.1 Acceptance of Terms**: By accepting this Terms of Service (TOS) or by accessing or using the Service, the Customer acknowledges that this TOS constitutes a legally binding agreement, enforceable in its electronic form. The Customer, as a legally constituted entity within its jurisdiction of formation, agrees to comply with all terms contained herein. The party entering into this Agreement is Holistics Software Pte Ltd, a corporation incorporated under the laws of Singapore and designated as a private company limited by shares, herein referred to as "Holistics." .
**2.2 Interpretation of Titles and Headings**: Titles and headings of sections of this TOS are for convenience only and shall not affect the construction of any provision of this Agreement.
**2.3 Entire Agreement and Order of Precedence**. This TOS is the entire agreement between Holistics and Customer regarding Customer’s use of Services and supersedes all prior and contemporaneous agreements, proposals or representations, written or oral, concerning its subject matter. The parties agree that any term or condition stated in a Customer purchase order or in any other Customer order documentation (excluding Subscription Agreements) is void. In the event of any conflict or inconsistency among the following documents, the order of precedence shall be:
- **2.3.1: The applicable Subscription Agreement(s)** - This is specific to the services purchased and contains terms tailored to the individual transaction. It overrides other documents for aspects specifically addressed therein.
- **2.3.2: Custom Terms of Service** - This document provides customized terms for certain customers based on specific agreements and will take precedence over the Standard TOS where applicable.
- **2.3.3: This Standard Terms of Service (TOS)** - Governs the general use of Holistics Services and applies to all customers unless superseded by more specific agreements as noted above.
- **2.3.4: The online documentation** of the applicable Holistics Individual Product(s) - This includes user manuals, product guidelines, and operational procedures that provide detailed information about the use and limitations of the services but do not override the legally binding terms found in the aforementioned documents.
**2.4 Relationship of the Parties**. This TOS does not create a partnership, franchise, joint venture, agency, fiduciary or employment relationship between the parties. Each party will be solely responsible for payment of all compensation owed to its employees, as well as all employment-related taxes.
**2.5 Anti-Corruption**. Neither party has received or been offered any illegal or improper bribe, kickback, payment, gift, or thing of value from an employee or agent of the other party in connection with this TOS. Reasonable gifts and entertainment provided in the ordinary course of business do not violate the above restriction
**2.6 Customer Compliance**: The Customer must comply with all applicable laws and regulations in their use of the Holistics Subscription Service and ensure all data provided or used is lawful and properly authorized. Additionally, Customers are prohibited from engaging in any unlawful activities including, but not limited to, unauthorized replication or modification of the service’s functionality, creation of derivative works, reverse engineering, and unauthorized access to source code. Violations of these provisions may lead to termination of service and legal action to protect Holistics' proprietary interests.
**2.7 Rights Reserved by Holistics**: Holistics' failure to enforce any provision of this TOS does not waive its right to do so in the future.
2.8 End-of-Support: Holistics reserves the right to discontinue support for features that have reached their end-of-support as communicated through official documentation and email announcements. Customers are advised to review these communications regularly to stay informed about which features are currently supported and any changes in support availability.
**2.9 Rights and Assignments**: The TOS cannot be assigned by the Customer without Holistics' consent, but Holistics may transfer this TOS with notice.
**2.10 Severability**. If any provision of this TOS is held by a court of competent jurisdiction to be contrary to law, the provision will be deemed null and void, and the remaining provisions of this Agreement will remain in effect.
**2.11 Amendments to TOS**: Holistics may revise these Terms from time to time to reflect changes in its services, laws, or regulatory requirements. If the Customer has an active Holistics subscription, Holistics will notify the Customer of any updates to the terms either via in-app notification or by email, provided that the Customer has opted to receive email updates.
## 3. Intellectual Property and Rights
**3.1 Intellectual Property Ownership**: Holistics retains all rights, titles, and interests, including intellectual property rights, in the Subscription Services. All rights not expressly granted to the Customer are reserved.
**3.2 Grant of Use**: Holistics grants the Customer a limited, worldwide, non-exclusive, non-transferable right to use the Subscription Service for internal business purposes, subject to compliance with this TOS. The Customer is entitled to access all functionality of the Subscription Service available as of the effective date stated in the Subscription Agreement.
**3.3 Metadata Rights**: Holistics may monitor Customer’s use of the Services and compile statistical and performance information in an aggregate and anonymous manner (“Metadata”), including to enhance service provision and operation. Holistics retains all intellectual property rights in such Metadata. Holistics shall ensure the use of Metadata respects the privacy and confidentiality agreements with the Customer and shall exclude personally identifiable information unless explicitly authorized. Customer agrees that Holistics may make the Metadata publicly available provided it does not incorporate any personally identifiable information or confidential Customer Data, nor does it identify Customer or its Confidential Information.
**3.4 Customer Feedback**: Holistics encourages feedback from all customers through our support communication channels. Customers agree that any feedback, suggestions, ideas, or other inputs provided to Holistics ("Feedback") will be considered non-confidential and non-proprietary. Holistics shall have a perpetual, irrevocable, worldwide, royalty-free license, including the right to sublicense, use, copy, modify, create derivative works from, and otherwise exploit any such Feedback for any purpose, without obligation or compensation to the provider. This Feedback may include, but is not limited to, improvements to Holistics' products, services, or processes.
**3.5 Publicity**: The Customer hereby grants Holistics the non-exclusive right to use the Customer's name and company logo in our customer list and on our website for marketing and promotional purposes. If the Customer wishes not to have their name and logo used in this manner, they may opt-out at any time by completing the Publicity Opt-Out Form available at [https://go.holistics.io/logo-opt-out](https://go.holistics.io/logo-opt-out).
## 4. Billing, Fees and Payment Terms
**4.1 Payment Obligation**: The Customer is committed to fulfilling all payment obligations for fees associated with the Subscription Service, as detailed in the Subscription Agreement. These fees are payable in advance, underscoring the Customer’s responsibility to ensure timely payment as part of their agreement with Holistics.
**4.2 Non-Refundable Fees**: Fees for the Subscription Service, as outlined in the Subscription Agreement, are non-refundable and non-cancelable, except where specifically stated in this Agreement. This clause affirms the binding nature of the payment commitment by the Customer for the agreed Subscription Term.
**4.3 Obligation to Maintain Current Billing Information**: Customers are required to keep their billing information up to date and accurate, including their credit card information for the payment of fees. This responsibility extends to all information necessary for the processing of payments, such as legal company name, address (especially state and country), and the primary contact.
**4.4 Credit Card Information Security**: Holistics does not store credit card details on our servers, ensuring customer payment information is secure. Billing is conducted through reputable third-party billing agents employing industry-standard security measures.
**4.5 Secure Transaction Processing**: Customers authorize Holistics to charge their credit card for all subscription fees during the Subscription Term. This process is securely managed through trusted third-party processors, with whom customers agree to share their payment information.
**4.6 Standard Payment Terms**: Payments are billed in advance via credit card and are due immediately upon billing. In the event of a failed credit card payment, Holistics reserves the right to retry billing the customer's credit card. This includes instances where the credit card has expired or is no longer valid. Holistics may automatically resume billing upon the customer updating their credit card information.
**4.7 Custom Payment Terms**: For eligible customers on annual billing plans that exceed a minimum subscription amount, Holistics offers custom payment terms including the option to pay via bank transfer. A deposit may be required, and administrative fees apply if conditions are not met.
**4.8 Responsibility for Bank Transfer Fees**: Customers opting for bank transfer payments must ensure that the net amount received by Holistics equals the invoiced amount, accounting for any fees charged by their bank for the international transfer. Holistics will cover any fees charged by its bank for receiving the funds.
**4.9 Customer Tax Responsibility**: All Subscription fees quoted or charged by Holistics are exclusive of government-imposed sales taxes, levies, duties, or similar governmental assessments of any nature, including but not limited to value-added, sales, use, or withholding taxes. Customers are responsible for paying all such taxes associated with their purchases under this agreement. If Holistics has the legal obligation to pay or collect any of these taxes for which the Customer is responsible, Holistics will invoice these to the Customer, and the Customer will pay that amount unless they provide Holistics with a valid tax exemption certificate authorized by the appropriate taxing authority. For clarity, Holistics is solely responsible for taxes assessable against it based on its income, property, and employees.
**4.10 Renewal Fee Adjustments**. Upon renewal, Holistics reserves the right to adjust the Subscription Fees payable by the Customer up to the then-current list price as detailed on the public pricing page of our Individual Products. Holistics will provide the Customer with a notice of any such fee adjustments at least thirty (30) days prior to the end of the Customer's then-current Subscription Term. These adjusted fees shall be effective commencing from the first day of the subsequent renewal term.
**4.11 Overdue Charges**: Late payments are subject to a monthly penalty of 1.5% of the overdue amount, calculated using the prevailing exchange rate from the date of default.
**4.12 Suspension for Non-Payment**: If the Customer's payment is overdue, Holistics will issue up to three reminders to the billing contact(s) specified in the applicable Individual Product Terms. These reminders may consist of notifications for pending bank transfer payments or failed payment retry attempts for other automated payment methods (credit card payments) as detailed in Section 4.6. If the overdue payment is not resolved following these reminders, Holistics reserves the right to suspend access to the Subscription Services. The specific timeline for initiating suspension due to non-payment, and the process for lifting such suspensions, is detailed in the Individual Product Terms in Section 6 and Section 7 of this TOS.
**4.13 Payment Disputes**: Holistics will not exercise its rights under the “Overdue Charges” or “Suspension for Non-Payment” of this section if Customer is disputing the applicable charges reasonably and in good faith and is cooperating diligently to resolve the dispute.
## 5. Term, Termination, and Suspension
**5.1 Duration and Renewal**. The initial term of the subscription shall commence as specified in the Subscription Agreement executed between the Customer and Holistics. Unless otherwise agreed in the said Subscription Agreement, the subscription shall automatically renew for the same term length or one year, whichever is shorter.
**5.2 Non-Renewal**. To prevent automatic renewal, the Customer must either cancel the subscription via the in-app billing page on their Holistics Individual Product’s application anytime before the renewal deadline or email a written notice of their intention not to renew at least three working days before the current subscription term ends. Detailed instructions for submitting a non-renewal notice or for early termination are available in the Individual Product Terms and the Holistics online documentation. It is the responsibility of the Customer to follow these procedures to ensure proper processing of their request
**5.3 Early Termination by Customer**. The Customer may terminate the subscription prior to the end of the term by providing thirty (30) days written notice. Upon such early termination, Holistics will not refund any prepaid fees or unused subscription fees. However, the Customer retains the right to continue using the Subscription Services until the end of the originally agreed term. The Customer remains obligated to settle any outstanding fees for the remaining subscription term.
**5.4 Termination for Cause**: This clause applies to any or all Subscription Services of the Individual Products under this TOS (including all related Subscription Agreements). Either party may terminate this Agreement for cause under the following conditions:
- **5.5.1 Material Breach**: Upon thirty (30) days' notice to the other party of a material breach if such breach remains uncured at the expiration of such period.
- **5.5.2 Insolvency and Bankruptcy**: Immediately, if the other party becomes the subject of a petition in bankruptcy or any proceeding relating to insolvency, cessation of business, liquidation, or assignment for the benefit of creditors, or if any such proceeding is instituted against such party (and not dismissed within sixty (60) days).
- **5.5.3 Cessation of Operations**: Immediately if the other party ceases its business operations without a successor.
- **5.5.4 Detrimental Conduct**: Upon thirty (30) days' written notice if one party reasonably determines that the other's conduct is damaging or could potentially damage the party’s reputation, business relationships, or operations. This includes, but is not limited to, engaging in illegal activities, fostering a hostile work environment, or other actions deemed significantly injurious to the other party's interests..
If the Customer terminates this Agreement for cause, Holistics will promptly refund any prepaid but unused fees covering the use of the Subscription Service after termination.
**5.6 Suspension for Cause**: Holistics may suspend the Customer's access to the Subscription Services, wholly or in part, under the following conditions:
- **5.6.1 Service Integrity**: If the Customer's use of the Subscription Services poses an immediate threat to the security, reliability, or integrity of the services, Holistics may suspend service access with immediate effect and will notify the Customer with the reason for suspension as soon as reasonably practicable.
- **5.6.2 Non-Payment**: For conditions leading to suspension due to non-payment, refer to the specific terms set out in Section 4.12. Suspension for non-payment will not apply if the Customer is disputing the overdue charges reasonably and in good faith. Upon resolution of the conditions leading to the suspension, Holistics will promptly restore the Customer's access to the services.
**5.7 Termination Beyond Suspension**: The right to suspend service does not limit Holistics' right to terminate the Agreement for cause as outlined in this section, particularly if the Customer's actions have, or may negatively reflect on or affect Holistics, its prospects, or its customers.
**5.8 Support Conduct Policy**: In the event of customer misuse or abuse of support services, or disrespectful conduct towards Holistics staff, Holistics reserves the right to limit or suspend support services to the offending customer. This policy is essential to maintaining a respectful and effective support environment and ensures that our support resources are used appropriately.
**5.9 Non-Exclusivity of Termination Remedies**: Termination is not an exclusive remedy, and the exercise by either party of any remedy under this TOS will be without prejudice to any other remedies it may have under this TOS, by law, or otherwise.
## 6. Individual Product Terms for Holistics Business Intelligence (BI)
**6.1 Customer Data for Holistics BI**:
**“Customer Data for Holistics BI”** shall encompass all data and information provided by or on behalf of the Customer in connection with their use of the Holistics Business Intelligence (BI) software. Customer Data is classified into the following categories:
- **6.1.1: “Customer Database” or “Customer Databases”** refers to any and all data records stored within the databases connected to the Holistics BI software by the Customer, and the database user credentials necessary for such connection. It is important to note that Holistics does not store, warehouse, or retain any raw physical data records contained within the Customer’s databases. Instead, Holistics queries the data directly from the Customer's databases in real-time when a report is loaded, ensuring data privacy and minimizing data exposure.
- **6.1.2. “Query Results Cache” or “Cache”** refers to the output results of SQL queries executed against the Customer’s Database. These results are temporarily cached within the Holistics system to enhance software performance and reduce the load on the Customer’s Database. Cached data is subject to automatic expiration after a customer-defined duration, with a minimum cache duration of ten minutes. Detailed information on the caching mechanism employed by Holistics is available at Holistics Data Caching Documentation.
- **6.1.3. “Application Metadata”** includes data pertaining to the usage of the Holistics software by the Customer, as well as descriptive information inputted by the Customer to label, contextualize, and define the logic of data within the Holistics platform. This category encompasses, without limitation, report titles, column and formatting settings, data field formulas, logic, labels, analytics modeling definitions, and data delivery recipients. Application Metadata serves to facilitate the organization, interpretation, and application of analytics definitions to facilitate self-service analytics.
**6.2 Data Center Locations**: Holistics BI operates globally, with data centers in the US, Europe, and Asia. Customers can select the data center where their databases will be processed, in compliance with local data residency laws. More information on our data center locations is available at [https://docs.holistics.io/docs/data-centers](https://docs.holistics.io/docs/data-centers).
**6.3 Support Response Times**: Holistics BI responds to most support tickets within 2 business days for standard non-critical issues, often responding even quicker. More details can be found at [https://docs.holistics.io/docs/customer-support/support-sla](https://docs.holistics.io/docs/customer-support/support-sla)
**6.4 System Uptime**: Holistics BI targets for our system availability and uptime to be 99.5%. Real-time status of our system up-time can be found at [https://status.holistics.io](https://status.holistics.io)
**6.5 Support Impersonation**: Our support team may request permission to enable impersonation in-app for troubleshooting support tickets effectively. Users can disable this permission in-app at any time, and access in-app activity monitoring for transparency. Details on support impersonation are available at [https://docs.holistics.io/docs/customer-support/support-impersonation](https://docs.holistics.io/docs/customer-support/support-impersonation)
**6.6 Data Retention and Deletion**: Upon termination of the Subscription Service due to subscription cancellation or the expiration of a trial period, Holistics will retain the Customer's data for a period of 180 days, or upon the customer’s request, whichever is earlier. Following this period, the data will be automatically removed from the system. This retention period allows Customers the opportunity to reactivate their subscription or retrieve their data before permanent deletion. Details on Data Retention can be found at [https://docs.holistics.io/docs/data-retention-period](https://docs.holistics.io/docs/data-retention-period)
**6.7 Suspension for Non-Payment**: If payment for Holistics BI is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 30 days, access to the Service may be suspended.
**6.8 Legacy Products**: For Customers using legacy products from Holistics BI, it is essential to recognize that access to the full suite of features available in newer versions may be limited. Holistics enables Customers to verify their product version in-app within each Individual’s Product. Should a Customer determine they are using a legacy version and wish to upgrade, executing a new Subscription Agreement with Holistics is required to facilitate the transition to the most current product version.
## 7. Individual Product Terms for Holistics dbdiagram and dbdocs.
**7.1 Customer Data Definitions**: “Customer Data” shall encompass all data and information provided by or on behalf of the Customer and is classified into the following categories:
- **7.1.1 “Customer Data for dbdiagram”** refers to the Entity Relationship Diagrams metadata stored on dbdiagram software by the Customer, including the information necessary for defining the database structure and visualizing the entity relationship diagram.
- **7.1.2 “Customer Data for dbdocs”** refers to the Database Documentation metadata stored on dbdocs software by the Customer, including the information necessary for defining the database structure and visualizing the database documentation.
- **7.1.3 Customer Data is only Metadata only**: For avoidance of doubt, both dbdiagram and dbdocs contain only metadata, and do not connect, store, and/or contain any live database records or credentials
**7.2 Data Center Location**: dbdiagram and dbdocs are hosted with a reputable data center provider in Singapore, Asia.
**7.3 Support Response Times**: Response time during support hours is typically within 2 business days, and more details can be found at [https://dbdiagram.io/docs/support-sla](https://dbdiagram.io/docs/support-sla)
**7.4 System Uptime**: dbdocs and dbdiagram target our system availability and uptime to be 99.5%. The real-time status of our system up-time can be found at [https://status.dbdiagram.io](https://status.dbdiagram.io)
**7.5 Data Retention and Deletion**: Customer Data is retained permanently unless users submit a request to remove their account and all data related to them [https://dbdiagram.io/docs/faqs/remove-account](https://dbdiagram.io/docs/faqs/remove-account)
**7.6 Suspension for Non-Payment**: If payment for dbdiagram and/or dbdocs is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 9 days, access to the Service may be suspended.
## 8. Confidentiality
**8.1 Confidential Information**: “Confidential Information” means all information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether orally or in writing, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and the circumstances of disclosure. For avoidance of doubt,
- **8.1.1: Confidential Information of Customer** refers to the Customer Data as mentioned in the Individual Product Terms.
- **8.1.2: Confidential Information of Holistics** refers to the Services of the Individual Product(s) subscribed by the Customer, the terms and conditions of this Agreement, custom pricing plans, and all exceptional commercial arrangements Holistics made for the Customer and are not available to the majority of other Customers.
- **8.1.3: Mutual Confidential Information** includes business and marketing plans, technology and technical information, product plans and designs, and business processes disclosed by such party.
**8.2 Confidential Information Exclusions**: Confidential Information does not include any information of the below categories:
- **8.2.1 Public Knowledge**: Information that is or becomes generally known to the public without breach of any obligation owed to the Disclosing Party,
- **8.2.2 Prior Knowledge**: Information was known to the Receiving Party prior to its disclosure by the Disclosing Party without breach of any obligation owed to the Disclosing Party,
- **8.2.3 Third-Party Information**: Information that is received from a third party without knowledge of any breach of any obligation owed to the Disclosing Party, or
- **8.2.4 Independent Development**: Information that was independently developed by the Receiving Party.
**8.3 Application of Confidentiality Obligations to Additional Services Evaluation**: For the avoidance of doubt, the non-disclosure obligations set forth in this “Confidentiality” section apply to Confidential Information exchanged between the parties in connection with the evaluation of each Individual Product.
**8.4 Protection of Confidential Information**: Each party agrees to take reasonable measures, at least substantially equivalent to the measures it takes to protect its own confidential information, to protect the confidentiality and avoid the unauthorized use, disclosure, publication, or dissemination of the other party’s Confidential Information.
**8.5 Data Minimization in Communications**: Customers are required to limit the sharing or exposure of sensitive or confidential data within communications, including support tickets. Information shared should be strictly necessary and relevant for the resolution of inquiries or issues. This practice of data minimization is crucial to protect sensitive information and minimize the risk of unauthorized data exposure.
**8.6. Customer Data Protection**: Holistics is dedicated to the security and confidentiality of Customer Data defined in our Individual Product Terms. We employ robust technical and organizational measures to protect against unauthorized access and loss. This commitment extends to maintaining commercially appropriate administrative, physical, and technical safeguards, as detailed in our Data Processing Agreement (DPA), including the Security Measures outlined in Annex 2 of the DPA. Further, we process Customer Data with a rigorously evaluated and approved list of sub-processors, specified in the DPA, chosen for their compliance with applicable data protection laws and standards, ensuring the highest level of security and confidentiality.
**8.7 Notification of Breach**: In the event of any breach of confidentiality or a data breach affecting Confidential Data, the Receiving Party shall promptly notify the Disclosing Party without unreasonable delay, considering the severity and volume of the breach in line with applicable regulations. The notification will include details of the breach, the steps taken to address it, efforts to regain possession of Confidential Information, and prevent its further unauthorized use, and any actions that affected parties should consider to protect themselves.
## 9. Warranty and Indemnity
**9.1 Service Warranty**: Holistics warrants that the Subscription Service will be performed in a manner consistent with generally accepted industry standards. This warranty does not apply to Free Services.
**9.2 Correction for Non-Conformance**: If the Subscription Service does not conform to the above warranty, Holistics shall correct any material reproducible impairments to the features and functionality of the Service so that it materially conforms to the applicable warranty within a commercially reasonable time following receipt of written notice of breach.
**9.3 Limitations**: Holistics is not liable under this warranty section if the non-conformance is caused by
- (i) combining the Subscription Service with any hardware, software, equipment, or data not supplied by Holistics,
- (ii) any modification of the Subscription Service by any party other than Holistics, or modifications made by Holistics per specifications or instructions provided by the Customer,
- (iii) use of the Subscription Service in violation of or outside the scope of this Agreement, or
- (iv) interruptions to the Subscription Service, including but not limited to outages, that are beyond its reasonable control, such as internet disruptions, cyber-attacks, or hardware failures. Efforts to mitigate such interruptions shall be undertaken promptly, but reparation for downtime or data loss will not exceed the pro-rata service fees paid by the Customer for the duration of the interruption.
**9.4 Exclusive Remedy**: If Holistics is unable to correct the non-conformity within sixty (60) days from when you notify us of the issue ("Remedy Period"), then either party may terminate this TOS by providing the other party written notice within thirty (30) days after the end of the Remedy Period. Upon such termination for non-conformance from the Customer, Holistics will refund any prepaid but unused fees covering use of the Subscription Service after termination.
**9.5 Disclaimer of Warrantie**: Holistics provides the Subscription Service "as is" and does not make any representations or warranties regarding its suitability, reliability, availability, timeliness, security, accuracy, or completeness. This includes all implied warranties or conditions of merchantability, fitness for a particular purpose, title, and non-infringement. Application Programming Interfaces (APIs) and other features may not be available at all times and are subject to maintenance and updates.
Holistics does not warrant that the Subscription Service will be uninterrupted or error-free; use of the service may be affected by numerous factors outside our control. Except as expressly stated in Section 9.1 "Service Warranty," these exclusions apply to the fullest extent permitted by law.
**9.6 Indemnification**: Customer will defend, indemnify, and hold harmless Holistics, its affiliates, officers, directors, employees, agents, suppliers, licensors, and assigns from and against any claims, actions, proceedings, losses, damages, expenses, and costs (including but not limited to court costs and reasonable attorney fees) arising out of or in connection with:
- **9.6.1 Unauthorized Use**: Unauthorized or illegal use of Holistics' Services, noncompliance with this Agreement, or actions exceeding the scope of services as permitted under this Agreement.
- **9.6.2 Third-Party Integrations**: Integration or use of Holistics' services with non-Holistics applications, data sources, or configurations that were not provided or explicitly approved by Holistics, including claims arising from third-party components that are part of the ecosystem but not endorsed or provided by Holistics.
- **9.6.3 Unauthorized Access**: Unauthorized access to Holistics' services through the Customer’s information or infrastructure, regardless of whether the Customer had knowledge of such access.
- **9.6.4 Modifications Without Consent**: Modifications to Holistics' services by the Customer or by third parties engaged by the Customer without Holistics' prior written consent, particularly if such modifications lead to the claims asserted.
- **9.6.5 Misconfiguration and Administrative Errors**: Misconfiguration of user permissions or security settings by the Customer, which could have been configured or restricted through the normal use of Holistics' services.
## 10. Free Services
**10.1 Definition of Free Services**: "Free Services" includes the Holistics Subscription Service offered on an unpaid trial, freemium, or open source offering basis.
**10.2 Disclaimer of Warranties**: Holistics provides the Free Services on an "as is" and "as available" basis without any warranties of any kind, either express or implied. Holistics expressly disclaims all warranties, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.
**10.3 Restrictions on Use**: Access to Free Services is intended solely for evaluating the potential purchase of a Holistics Service subscription. Use of the Free Services for business purposes is permitted; however, any use for competitive analysis, resale, or any form of commercialization of Holistics' services is strictly prohibited.
**10.4 Typical Use Limitations**: Customers agree not to use the Free Services in any manner that substantially exceeds typical use projections. This includes, but is not limited to, excessive storage and bandwidth consumption, to ensure fair access and resource allocation for all users of the Free Services.
**10.5 Support for Free Services**: If you do not pay a Subscription Fee, your support is available to you through the community pages of the Individual Products.
**10.6 Limitation of Liability**: Holistics shall not be liable for any indirect, incidental, special, consequential, or exemplary damages, including but not limited to, damages for loss of profits, goodwill, use, data, or other intangible losses, even if Holistics has been advised of the possibility of such damages, arising out of or in connection with the Free Services. In no event shall the total liability of Holistics for all damages, losses, and causes of action related to the Free Services exceed US$500 per year, regardless of the number of claims.
**10.7 Termination Rights**: Holistics reserves the right to suspend, limit, or terminate Free Services at any time without notice, including for reasons of inactivity, security concerns, or maintenance.
**10.8 Modification of Free Services**: Holistics reserves the right to modify or discontinue, temporarily or permanently, any or all of the Free Services with or without notice to the Customer(s). Holistics is not liable to the Customer(s) or to any third party for any modification, suspension, or discontinuance of the Free Services.
## 11. Account Information from Third-Party Providers
**11.1 Third-Party Information Retrieval**: Customers may direct Holistics to retrieve certain information maintained online by third-party providers with whom the customer has a customer-vendor relationship.
**11.2 Authorization for Access**: Holistics may require the customer to provide the login information necessary to access the customer's account with third-party providers with whom the customer has a customer relationship. By using the Service and providing Customer Access Information, customers expressly authorize Holistics to access and use their account information maintained by identified third parties, on their behalf as their agent.
**11.3 Customer Representations and Warranties**: Customers represent and warrant that neither the foregoing (nor anything else in this TOS) nor their use of the Services will violate any agreement or terms to which they are subject, including without limitation, those with respect to any third-party site.
**11.4 Agency Relationship and Liability Disclaimer**: Customers acknowledge and agree that when Holistics accesses and retrieves account information from third-party sites, Holistics acts as the customer's agent and not as the agent of or on behalf of the third party. As such, Holistics is not liable for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such third-party services.
**11.3 No Liability for Third-Party Services**: Holistics does not guarantee that any such third-party services will continue to be made available within the Service, and such services may be removed or disabled by Holistics at any time without notice to the customer. Customers acknowledge and agree that the Service may not be sponsored or endorsed by the third-party services accessible through the Service.
## 12. Limitation of Liability
**12.1 Exclusion of Certain Losses**: Neither party shall be liable to the other for any indirect, incidental, special, consequential, or punitive damages, including loss of profits, data, or use incurred by the other party, except as allowed under mandatory applicable law.
**12.2 Liability Cap**: Each party’s cumulative liability under this TOS in relation to liability arising from any given event or series of connected events shall be limited to the total amount paid by Customer in the twelve (12) months immediately preceding the month in which the event (or first in a series of connected events) occurred.
**12.3. Exclusion for Service Interruptions**: Holistics specifically excludes liability for any compensation, reimbursement, or damages arising from the Customer’s inability to use the services due to:
- **12.3.1**: Termination or suspension of this Agreement or the Customer's use of or access to the Service offerings;
- **12.3.2**: Discontinuation of any or all of the Service offerings by Holistics;
- **12.3.3**: Any unanticipated or unscheduled downtime of all or a portion of the services for any reason, including as a result of power outages, system failures, or other interruptions attributable to third-party hosting or infrastructure providers used by Holistics.
**12.4 Exclusion for Customer Mismanagement**: Holistics specifically excludes liability for any losses or damages arising from the Customer’s mismanagement of their use of the services. This includes, but is not limited to:
- **12.4.1**: Failure to follow adequate data security practices that prevent unauthorized access to their accounts;
- **12.4.2**: Incorrect or improper configuration of the service settings by the Customer;
- **12.4.3**: The Customer's provision of incorrect or incomplete information that is necessary for the proper provisioning and operation of the services;
- **12.4.4**: Unauthorized actions taken by the Customer or their agents that compromise the integrity or confidentiality of data processed through the services.
**12.5 Agreement to Liability Limit**: The liability limits herein are fundamental to the pricing of Holistics' services. By agreeing to these terms, the Customer acknowledges that accepting increased liability would require an adjustment to the pricing structure.
## 13. Governing Law and Dispute Resolution
**13.1 Governing Law**: This Agreement shall be governed by and construed in accordance with the laws of the Republic of Singapore, without regard to its conflict of law principles.
**13.2 Jurisdiction**: The parties irrevocably agree that the courts of Singapore shall have exclusive jurisdiction to settle any dispute or claim that arises out of or in connection with this Agreement or its subject matter or formation (including non-contractual disputes or claims).
**13.3: International Arbitration**: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre (“SIAC”) in accordance with the Arbitration Rules of the Singapore International Arbitration Centre (“SIAC Rules”) for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore. The Tribunal shall consist of a sole arbitrator. The language of the arbitration shall be English.
**13.4 Virtual Arbitration Proceedings**: Unless mutually agreed otherwise, all arbitration sessions shall be conducted virtually. If the Customer and Holistics cannot agree on an arbitrator, the respective arbitration institution will appoint an arbitrator experienced in the B2B SaaS or Enterprise software industry.
**13.5 Arbitration Award Enforcement**: The arbitration process will yield a binding award, which may be recognized and enforced by any court of competent jurisdiction, thus affirming the finality of the decision.
**13.6 Individual Capacity Only**: Both Customer and Holistics shall conduct any arbitration on an individual basis only, expressly waiving any right to initiate or participate in a class action or to seek relief on a class basis.
**13.7 Limitation on Arbitrable Remedies**: In line with the limitations in Section 12 (Limitation of Liability), the arbitrator is not authorized to award any indirect, special, incidental, or consequential damages, including but not limited to lost profits, arising from or related to this TOS.
---
## Terms of service (effective 3 October 2024)
:::warning Superseded version
This is the **3 October 2024** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Terms of service](/legal/terms).
:::
:::tip Note
To download our Terms of Service, visit: https://go.holistics.io/terms. Click on **File >** download **Download As** then select your preferred format.
:::
_Last Updated: 3 October 2024 (from 24 May 2024)_
## 1. Definitions
**"Affiliate"** means any entity, whether now in existence or subsequently created, which directly or indirectly controls, is controlled by, or is under common control with a party to this Agreement. Control for the purposes of this definition is evidenced by direct or indirect ownership or control of more than 50% of the voting interests of the subject entity. Examples of such relationships include, but are not limited to, subsidiaries (entities controlled by a party), parents (entities that control a party), and siblings (entities under common control with a party).
**“Customer”, “you”, or “your”** refers to the person or entity using the Subscription Service and identified in the applicable account record, billing statement, online subscription process, or Subscription Agreement as the customer and your Affiliates in the scope of your purchase.
**“Customer Data”** refers to data that Holistics stores, processes, and/or secures for each Individual Product subscribed by the Customer as defined in the Individual Product Terms.
**"Embedded Solution"** refers to the incorporation, integration, or inclusion of any Holistics service or functionality from any Individual Product within a customer’s or third party's application, interface, or system in such a manner that it appears part of or operates seamlessly as part of the customer’s or third party's product offering.
**"Holistics", “we”, "us", or “our”** refers to Holistics Software Pte Ltd, a corporation incorporated under Singapore law, designated as a private company limited by shares.
**“Individual Product”** refers to one of the below software developed by Holistics.
- **“Holistics BI”** refers to the business intelligence software from Holistics (www.holistics.io).
- **“Holistics dbdiagram”** refers to the database diagramming software from Holistics (www.dbdiagram.io).
- **“Holistics dbdocs”** refers to the database documentation software from Holistics (www.dbdocs.io)
- **“Individual Product Terms”** refers to the terms for any one of the specific mentioned software in Holistics described in this TOS.
**"Party"** represents either Holistics or the Customer as a business entity.
**"Subscription Service" or "Service"** includes the software, professional services, and technical support services provided by Holistics listed under the Individual Product. This includes any updates, enhancements, new features, documentation, and educational content provided or made available to the Customer.
**"Subscription Agreement" or “Order Form”** means any agreement confirming the purchase of the Holistics Service, whether executed through in-app self-service processes, or via electronic signature of a Holistics Order Form. It becomes legally binding upon digital or electronic signing by an authorized representative of the Customer or by the completion of the subscription process within the Holistics application, each with the same legal force as a handwritten signature.
**“User” or "Users"** means employees, representatives, consultants, contractors, or agents authorized by the Customer to use the Subscription Service and have unique user identifications and passwords.
## 2. General Terms
**2.1 Acceptance of Terms**: By accepting this Terms of Service (TOS) or by accessing or using the Service, the Customer acknowledges that this TOS constitutes a legally binding agreement, enforceable in its electronic form. The Customer, as a legally constituted entity within its jurisdiction of formation, agrees to comply with all terms contained herein. The party entering into this Agreement is Holistics Software Pte Ltd, a corporation incorporated under the laws of Singapore and designated as a private company limited by shares, herein referred to as "Holistics." .
**2.2 Interpretation of Titles and Headings**: Titles and headings of sections of this TOS are for convenience only and shall not affect the construction of any provision of this Agreement.
**2.3 Entire Agreement and Order of Precedence**. This TOS is the entire agreement between Holistics and Customer regarding Customer’s use of Services and supersedes all prior and contemporaneous agreements, proposals or representations, written or oral, concerning its subject matter. The parties agree that any term or condition stated in a Customer purchase order or in any other Customer order documentation (excluding Subscription Agreements) is void. In the event of any conflict or inconsistency among the following documents, the order of precedence shall be:
- **2.3.1: The applicable Subscription Agreement(s)** - This is specific to the services purchased and contains terms tailored to the individual transaction. It overrides other documents for aspects specifically addressed therein.
- **2.3.2: Custom Terms of Service** - This document provides customized terms for certain customers based on specific agreements and will take precedence over the Standard TOS where applicable.
- **2.3.3: This Standard Terms of Service (TOS)** - Governs the general use of Holistics Services and applies to all customers unless superseded by more specific agreements as noted above.
- **2.3.4: The online documentation** of the applicable Holistics Individual Product(s) - This includes user manuals, product guidelines, and operational procedures that provide detailed information about the use and limitations of the services but do not override the legally binding terms found in the aforementioned documents.
**2.4 Relationship of the Parties**. This TOS does not create a partnership, franchise, joint venture, agency, fiduciary or employment relationship between the parties. Each party will be solely responsible for payment of all compensation owed to its employees, as well as all employment-related taxes.
**2.5 Anti-Corruption**. Neither party has received or been offered any illegal or improper bribe, kickback, payment, gift, or thing of value from an employee or agent of the other party in connection with this TOS. Reasonable gifts and entertainment provided in the ordinary course of business do not violate the above restriction
**2.6 Customer Compliance**: The Customer must comply with all applicable laws and regulations in their use of the Holistics Subscription Service and ensure all data provided or used is lawful and properly authorized. Additionally, Customers are prohibited from engaging in any unlawful activities including, but not limited to, unauthorized replication or modification of the service’s functionality, creation of derivative works, reverse engineering, and unauthorized access to source code. Violations of these provisions may lead to termination of service and legal action to protect Holistics' proprietary interests.
**2.7 Rights Reserved by Holistics**: Holistics' failure to enforce any provision of this TOS does not waive its right to do so in the future.
2.8 End-of-Support: Holistics reserves the right to discontinue support for features that have reached their end-of-support as communicated through official documentation and email announcements. Customers are advised to review these communications regularly to stay informed about which features are currently supported and any changes in support availability.
**2.9 Rights and Assignments**: The TOS cannot be assigned by the Customer without Holistics' consent, but Holistics may transfer this TOS with notice.
**2.10 Severability**. If any provision of this TOS is held by a court of competent jurisdiction to be contrary to law, the provision will be deemed null and void, and the remaining provisions of this Agreement will remain in effect.
**2.11 Amendments to TOS**: Holistics may revise these Terms from time to time to reflect changes in its services, laws, or regulatory requirements. If the Customer has an active Holistics subscription, Holistics will notify the Customer of any updates to the terms either via in-app notification or by email, provided that the Customer has opted to receive email updates.
## 3. Intellectual Property and Rights
**3.1 Intellectual Property Ownership**: Holistics retains all rights, titles, and interests, including intellectual property rights, in the Subscription Services. All rights not expressly granted to the Customer are reserved.
**3.2 Grant of Use**: Holistics grants the Customer a limited, worldwide, non-exclusive, non-transferable right to use the Subscription Service for internal business purposes, subject to compliance with this TOS. The Customer is entitled to access all functionality of the Subscription Service available as of the effective date stated in the Subscription Agreement.
**3.3 Metadata Rights**: Holistics may monitor Customer’s use of the Services and compile statistical and performance information in an aggregate and anonymous manner (“Metadata”), including to enhance service provision and operation. Holistics retains all intellectual property rights in such Metadata. Holistics shall ensure the use of Metadata respects the privacy and confidentiality agreements with the Customer and shall exclude personally identifiable information unless explicitly authorized. Customer agrees that Holistics may make the Metadata publicly available provided it does not incorporate any personally identifiable information or confidential Customer Data, nor does it identify Customer or its Confidential Information.
**3.4 Customer Feedback**: Holistics encourages feedback from all customers through our support communication channels. Customers agree that any feedback, suggestions, ideas, or other inputs provided to Holistics ("Feedback") will be considered non-confidential and non-proprietary. Holistics shall have a perpetual, irrevocable, worldwide, royalty-free license, including the right to sublicense, use, copy, modify, create derivative works from, and otherwise exploit any such Feedback for any purpose, without obligation or compensation to the provider. This Feedback may include, but is not limited to, improvements to Holistics' products, services, or processes.
**3.5 Publicity**: The Customer hereby grants Holistics the non-exclusive right to use the Customer's name and company logo in our customer list and on our website for marketing and promotional purposes. If the Customer wishes not to have their name and logo used in this manner, they may opt-out at any time by completing the Publicity Opt-Out Form available at [https://go.holistics.io/logo-opt-out](https://go.holistics.io/logo-opt-out).
## 4. Billing, Fees and Payment Terms
**4.1 Payment Obligation**: The Customer is committed to fulfilling all payment obligations for fees associated with the Subscription Service, as detailed in the Subscription Agreement. These fees are payable in advance, underscoring the Customer’s responsibility to ensure timely payment as part of their agreement with Holistics.
**4.2 Non-Refundable Fees**: Fees for the Subscription Service, as outlined in the Subscription Agreement, are non-refundable and non-cancelable, except where specifically stated in this Agreement. This clause affirms the binding nature of the payment commitment by the Customer for the agreed Subscription Term.
**4.3 Obligation to Maintain Current Billing Information**: Customers are required to keep their billing information up to date and accurate, including their credit card information for the payment of fees. This responsibility extends to all information necessary for the processing of payments, such as legal company name, address (especially state and country), and the primary contact.
**4.4 Credit Card Information Security**: Holistics does not store credit card details on our servers, ensuring customer payment information is secure. Billing is conducted through reputable third-party billing agents employing industry-standard security measures.
**4.5 Secure Transaction Processing**: Customers authorize Holistics to charge their credit card for all subscription fees during the Subscription Term. This process is securely managed through trusted third-party processors, with whom customers agree to share their payment information.
**4.6 Standard Payment Terms**: Payments are billed in advance via credit card and are due immediately upon billing. In the event of a failed credit card payment, Holistics reserves the right to retry billing the customer's credit card. This includes instances where the credit card has expired or is no longer valid. Holistics may automatically resume billing upon the customer updating their credit card information.
**4.7 Custom Payment Terms**: For eligible customers on annual billing plans that exceed a minimum subscription amount, Holistics offers custom payment terms including the option to pay via bank transfer. A deposit may be required, and administrative fees apply if conditions are not met.
**4.8 Responsibility for Bank Transfer Fees**: Customers opting for bank transfer payments must ensure that the net amount received by Holistics equals the invoiced amount, accounting for any fees charged by their bank for the international transfer. Holistics will cover any fees charged by its bank for receiving the funds.
**4.9 Customer Tax Responsibility**: All Subscription fees quoted or charged by Holistics are exclusive of government-imposed sales taxes, levies, duties, or similar governmental assessments of any nature, including but not limited to value-added, sales, use, or withholding taxes. Customers are responsible for paying all such taxes associated with their purchases under this agreement. If Holistics has the legal obligation to pay or collect any of these taxes for which the Customer is responsible, Holistics will invoice these to the Customer, and the Customer will pay that amount unless they provide Holistics with a valid tax exemption certificate authorized by the appropriate taxing authority. For clarity, Holistics is solely responsible for taxes assessable against it based on its income, property, and employees.
**4.10 Renewal Fee Adjustments**. Upon renewal, Holistics reserves the right to adjust the Subscription Fees payable by the Customer up to the then-current list price as detailed on the public pricing page of our Individual Products. Holistics will provide the Customer with a notice of any such fee adjustments at least thirty (30) days prior to the end of the Customer's then-current Subscription Term. These adjusted fees shall be effective commencing from the first day of the subsequent renewal term.
**4.11 Overdue Charges**: Late payments are subject to a monthly penalty of 1.5% of the overdue amount, calculated using the prevailing exchange rate from the date of default.
**4.12 Suspension for Non-Payment**: If the Customer's payment is overdue, Holistics will issue up to three reminders to the billing contact(s) specified in the applicable Individual Product Terms. These reminders may consist of notifications for pending bank transfer payments or failed payment retry attempts for other automated payment methods (credit card payments) as detailed in Section 4.6. If the overdue payment is not resolved following these reminders, Holistics reserves the right to suspend access to the Subscription Services. The specific timeline for initiating suspension due to non-payment, and the process for lifting such suspensions, is detailed in the Individual Product Terms in Section 6 and Section 7 of this TOS.
**4.13 Payment Disputes**: Holistics will not exercise its rights under the “Overdue Charges” or “Suspension for Non-Payment” of this section if Customer is disputing the applicable charges reasonably and in good faith and is cooperating diligently to resolve the dispute.
## 5. Term, Termination, and Suspension
**5.1 Duration and Renewal**. The initial term of the subscription shall commence as specified in the Subscription Agreement executed between the Customer and Holistics. Unless otherwise agreed in the said Subscription Agreement, the subscription shall automatically renew for the same term length or one year, whichever is shorter.
**5.2 Non-Renewal**. To prevent automatic renewal, the Customer must either cancel the subscription via the in-app billing page on their Holistics Individual Product’s application anytime before the renewal deadline or email a written notice of their intention not to renew at least three working days before the current subscription term ends. Detailed instructions for submitting a non-renewal notice or for early termination are available in the Individual Product Terms and the Holistics online documentation. It is the responsibility of the Customer to follow these procedures to ensure proper processing of their request
**5.3 Early Termination by Customer**. The Customer may terminate the subscription prior to the end of the term by providing thirty (30) days written notice. Upon such early termination, Holistics will not refund any prepaid fees or unused subscription fees. However, the Customer retains the right to continue using the Subscription Services until the end of the originally agreed term. The Customer remains obligated to settle any outstanding fees for the remaining subscription term.
**5.4 Termination for Cause**: This clause applies to any or all Subscription Services of the Individual Products under this TOS (including all related Subscription Agreements). Either party may terminate this Agreement for cause under the following conditions:
- **5.5.1 Material Breach**: Upon thirty (30) days' notice to the other party of a material breach if such breach remains uncured at the expiration of such period.
- **5.5.2 Insolvency and Bankruptcy**: Immediately, if the other party becomes the subject of a petition in bankruptcy or any proceeding relating to insolvency, cessation of business, liquidation, or assignment for the benefit of creditors, or if any such proceeding is instituted against such party (and not dismissed within sixty (60) days).
- **5.5.3 Cessation of Operations**: Immediately if the other party ceases its business operations without a successor.
- **5.5.4 Detrimental Conduct**: Upon thirty (30) days' written notice if one party reasonably determines that the other's conduct is damaging or could potentially damage the party’s reputation, business relationships, or operations. This includes, but is not limited to, engaging in illegal activities, fostering a hostile work environment, or other actions deemed significantly injurious to the other party's interests..
If the Customer terminates this Agreement for cause, Holistics will promptly refund any prepaid but unused fees covering the use of the Subscription Service after termination.
**5.6 Suspension for Cause**: Holistics may suspend the Customer's access to the Subscription Services, wholly or in part, under the following conditions:
- **5.6.1 Service Integrity**: If the Customer's use of the Subscription Services poses an immediate threat to the security, reliability, or integrity of the services, Holistics may suspend service access with immediate effect and will notify the Customer with the reason for suspension as soon as reasonably practicable.
- **5.6.2 Non-Payment**: For conditions leading to suspension due to non-payment, refer to the specific terms set out in Section 4.12. Suspension for non-payment will not apply if the Customer is disputing the overdue charges reasonably and in good faith. Upon resolution of the conditions leading to the suspension, Holistics will promptly restore the Customer's access to the services.
**5.7 Termination Beyond Suspension**: The right to suspend service does not limit Holistics' right to terminate the Agreement for cause as outlined in this section, particularly if the Customer's actions have, or may negatively reflect on or affect Holistics, its prospects, or its customers.
**5.8 Support Conduct Policy**: In the event of customer misuse or abuse of support services, or disrespectful conduct towards Holistics staff, Holistics reserves the right to limit or suspend support services to the offending customer. This policy is essential to maintaining a respectful and effective support environment and ensures that our support resources are used appropriately.
**5.9 Non-Exclusivity of Termination Remedies**: Termination is not an exclusive remedy, and the exercise by either party of any remedy under this TOS will be without prejudice to any other remedies it may have under this TOS, by law, or otherwise.
## 6. Individual Product Terms for Holistics Business Intelligence (BI)
**6.1 Customer Data for Holistics BI**:
**“Customer Data for Holistics BI”** shall encompass all data and information provided by or on behalf of the Customer in connection with their use of the Holistics Business Intelligence (BI) software. Customer Data is classified into the following categories:
- **6.1.1: “Customer Database” or “Customer Databases”** refers to any and all data records stored within the databases connected to the Holistics BI software by the Customer, and the database user credentials necessary for such connection. It is important to note that Holistics does not store, warehouse, or retain any raw physical data records contained within the Customer’s databases. Instead, Holistics queries the data directly from the Customer's databases in real-time when a report is loaded, ensuring data privacy and minimizing data exposure.
- **6.1.2. “Query Results Cache” or “Cache”** refers to the output results of SQL queries executed against the Customer’s Database. These results are temporarily cached within the Holistics system to enhance software performance and reduce the load on the Customer’s Database. Cached data is subject to automatic expiration after a customer-defined duration, with a minimum cache duration of ten minutes. Detailed information on the caching mechanism employed by Holistics is available at Holistics Data Caching Documentation.
- **6.1.3. “Application Metadata”** includes data pertaining to the usage of the Holistics software by the Customer, as well as descriptive information inputted by the Customer to label, contextualize, and define the logic of data within the Holistics platform. This category encompasses, without limitation, report titles, column and formatting settings, data field formulas, logic, labels, analytics modeling definitions, and data delivery recipients. Application Metadata serves to facilitate the organization, interpretation, and application of analytics definitions to facilitate self-service analytics.
**6.2 Data Center Locations**: Holistics BI operates globally, with data centers in the US, Europe, and Asia. Customers can select the data center where their databases will be processed, in compliance with local data residency laws. More information on our data center locations is available at [https://docs.holistics.io/docs/data-centers](https://docs.holistics.io/docs/data-centers).
**6.3 Support Response Times**: Holistics BI responds to most support tickets within 2 business days for standard non-critical issues, often responding even quicker. More details can be found at [https://docs.holistics.io/docs/customer-support/support-sla](https://docs.holistics.io/docs/customer-support/support-sla)
**6.4 System Uptime**: Holistics BI targets for our system availability and uptime to be 99.5%. Real-time status of our system up-time can be found at [https://status.holistics.io](https://status.holistics.io)
**6.5 Support Impersonation**: Our support team may request permission to enable impersonation in-app for troubleshooting support tickets effectively. Users can disable this permission in-app at any time, and access in-app activity monitoring for transparency. Details on support impersonation are available at [https://docs.holistics.io/docs/customer-support/support-impersonation](https://docs.holistics.io/docs/customer-support/support-impersonation)
**6.6 Data Retention and Deletion**: Upon termination of the Subscription Service due to subscription cancellation or the expiration of a trial period, Holistics will retain the Customer's data for a period of 180 days, or upon the customer’s request, whichever is earlier. Following this period, the data will be automatically removed from the system. This retention period allows Customers the opportunity to reactivate their subscription or retrieve their data before permanent deletion. Details on Data Retention can be found at [https://docs.holistics.io/docs/data-retention-period](https://docs.holistics.io/docs/data-retention-period)
**6.7 Suspension for Non-Payment**: If payment for Holistics BI is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 30 days, access to the Service may be suspended.
**6.8 Legacy Products**: For Customers using legacy products from Holistics BI, it is essential to recognize that access to the full suite of features available in newer versions may be limited. Holistics enables Customers to verify their product version in-app within each Individual’s Product. Should a Customer determine they are using a legacy version and wish to upgrade, executing a new Subscription Agreement with Holistics is required to facilitate the transition to the most current product version.
## 7. Individual Product Terms for Holistics dbdiagram and dbdocs.
**7.1 Customer Data Definitions**: “Customer Data” shall encompass all data and information provided by or on behalf of the Customer and is classified into the following categories:
- **7.1.1 “Customer Data for dbdiagram”** refers to the Entity Relationship Diagrams metadata stored on dbdiagram software by the Customer, including the information necessary for defining the database structure and visualizing the entity relationship diagram.
- **7.1.2 “Customer Data for dbdocs”** refers to the Database Documentation metadata stored on dbdocs software by the Customer, including the information necessary for defining the database structure and visualizing the database documentation.
- **7.1.3 Customer Data is only Metadata only**: For avoidance of doubt, both dbdiagram and dbdocs contain only metadata, and do not connect, store, and/or contain any live database records or credentials
**7.2 Data Center Location**: dbdiagram and dbdocs are hosted with a reputable data center provider in Singapore, Asia.
**7.3 Support Response Times**: Response time during support hours is typically within 2 business days, and more details can be found at [https://dbdiagram.io/docs/support-sla](https://dbdiagram.io/docs/support-sla)
**7.4 System Uptime**: dbdocs and dbdiagram target our system availability and uptime to be 99.5%. The real-time status of our system up-time can be found at [https://status.dbdiagram.io](https://status.dbdiagram.io)
**7.5 Data Retention and Deletion**: Customer Data is retained permanently unless users submit a request to remove their account and all data related to them [https://dbdiagram.io/docs/faqs/remove-account](https://dbdiagram.io/docs/faqs/remove-account)
**7.6 Suspension for Non-Payment**: If payment for dbdiagram and/or dbdocs is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 9 days, access to the Service may be suspended.
## 8. Confidentiality
**8.1 Confidential Information**: “Confidential Information” means all information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether orally or in writing, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and the circumstances of disclosure. For avoidance of doubt,
- **8.1.1: Confidential Information of Customer** refers to the Customer Data as mentioned in the Individual Product Terms.
- **8.1.2: Confidential Information of Holistics** refers to the Services of the Individual Product(s) subscribed by the Customer, the terms and conditions of this Agreement, custom pricing plans, and all exceptional commercial arrangements Holistics made for the Customer and are not available to the majority of other Customers.
- **8.1.3: Mutual Confidential Information** includes business and marketing plans, technology and technical information, product plans and designs, and business processes disclosed by such party.
**8.2 Confidential Information Exclusions**: Confidential Information does not include any information of the below categories:
- **8.2.1 Public Knowledge**: Information that is or becomes generally known to the public without breach of any obligation owed to the Disclosing Party,
- **8.2.2 Prior Knowledge**: Information was known to the Receiving Party prior to its disclosure by the Disclosing Party without breach of any obligation owed to the Disclosing Party,
- **8.2.3 Third-Party Information**: Information that is received from a third party without knowledge of any breach of any obligation owed to the Disclosing Party, or
- **8.2.4 Independent Development**: Information that was independently developed by the Receiving Party.
**8.3 Application of Confidentiality Obligations to Additional Services Evaluation**: For the avoidance of doubt, the non-disclosure obligations set forth in this “Confidentiality” section apply to Confidential Information exchanged between the parties in connection with the evaluation of each Individual Product.
**8.4 Protection of Confidential Information**: Each party agrees to take reasonable measures, at least substantially equivalent to the measures it takes to protect its own confidential information, to protect the confidentiality and avoid the unauthorized use, disclosure, publication, or dissemination of the other party’s Confidential Information.
**8.5 Data Minimization in Communications**: Customers are required to limit the sharing or exposure of sensitive or confidential data within communications, including support tickets. Information shared should be strictly necessary and relevant for the resolution of inquiries or issues. This practice of data minimization is crucial to protect sensitive information and minimize the risk of unauthorized data exposure.
**8.6. Customer Data Protection**: Holistics is dedicated to the security and confidentiality of Customer Data defined in our Individual Product Terms. We employ robust technical and organizational measures to protect against unauthorized access and loss. This commitment extends to maintaining commercially appropriate administrative, physical, and technical safeguards, as detailed in our Data Processing Agreement (DPA), including the Security Measures outlined in Annex 2 of the DPA. Further, we process Customer Data with a rigorously evaluated and approved list of sub-processors, specified in the DPA, chosen for their compliance with applicable data protection laws and standards, ensuring the highest level of security and confidentiality.
**8.7 Notification of Breach**: In the event of any breach of confidentiality or a data breach affecting Confidential Data, the Receiving Party shall promptly notify the Disclosing Party without unreasonable delay, considering the severity and volume of the breach in line with applicable regulations. The notification will include details of the breach, the steps taken to address it, efforts to regain possession of Confidential Information, and prevent its further unauthorized use, and any actions that affected parties should consider to protect themselves.
## 9. Warranty and Indemnity
**9.1 Service Warranty**: Holistics warrants that the Subscription Service will be performed in a manner consistent with generally accepted industry standards. This warranty does not apply to Free Services.
**9.2 Correction for Non-Conformance**: If the Subscription Service does not conform to the above warranty, Holistics shall correct any material reproducible impairments to the features and functionality of the Service so that it materially conforms to the applicable warranty within a commercially reasonable time following receipt of written notice of breach.
**9.3 Limitations**: Holistics is not liable under this warranty section if the non-conformance is caused by
- (i) combining the Subscription Service with any hardware, software, equipment, or data not supplied by Holistics,
- (ii) any modification of the Subscription Service by any party other than Holistics, or modifications made by Holistics per specifications or instructions provided by the Customer,
- (iii) use of the Subscription Service in violation of or outside the scope of this Agreement, or
- (iv) interruptions to the Subscription Service, including but not limited to outages, that are beyond its reasonable control, such as internet disruptions, cyber-attacks, or hardware failures. Efforts to mitigate such interruptions shall be undertaken promptly, but reparation for downtime or data loss will not exceed the pro-rata service fees paid by the Customer for the duration of the interruption.
**9.4 Exclusive Remedy**: If Holistics is unable to correct the non-conformity within sixty (60) days from when you notify us of the issue ("Remedy Period"), then either party may terminate this TOS by providing the other party written notice within thirty (30) days after the end of the Remedy Period. Upon such termination for non-conformance from the Customer, Holistics will refund any prepaid but unused fees covering use of the Subscription Service after termination.
**9.5 Disclaimer of Warrantie**: Holistics provides the Subscription Service "as is" and does not make any representations or warranties regarding its suitability, reliability, availability, timeliness, security, accuracy, or completeness. This includes all implied warranties or conditions of merchantability, fitness for a particular purpose, title, and non-infringement. Application Programming Interfaces (APIs) and other features may not be available at all times and are subject to maintenance and updates.
Holistics does not warrant that the Subscription Service will be uninterrupted or error-free; use of the service may be affected by numerous factors outside our control. Except as expressly stated in Section 9.1 "Service Warranty," these exclusions apply to the fullest extent permitted by law.
**9.6 Indemnification**: Customer will defend, indemnify, and hold harmless Holistics, its affiliates, officers, directors, employees, agents, suppliers, licensors, and assigns from and against any claims, actions, proceedings, losses, damages, expenses, and costs (including but not limited to court costs and reasonable attorney fees) arising out of or in connection with:
- **9.6.1 Unauthorized Use**: Unauthorized or illegal use of Holistics' Services, noncompliance with this Agreement, or actions exceeding the scope of services as permitted under this Agreement.
- **9.6.2 Third-Party Integrations**: Integration or use of Holistics' services with non-Holistics applications, data sources, or configurations that were not provided or explicitly approved by Holistics, including claims arising from third-party components that are part of the ecosystem but not endorsed or provided by Holistics.
- **9.6.3 Unauthorized Access**: Unauthorized access to Holistics' services through the Customer’s information or infrastructure, regardless of whether the Customer had knowledge of such access.
- **9.6.4 Modifications Without Consent**: Modifications to Holistics' services by the Customer or by third parties engaged by the Customer without Holistics' prior written consent, particularly if such modifications lead to the claims asserted.
- **9.6.5 Misconfiguration and Administrative Errors**: Misconfiguration of user permissions or security settings by the Customer, which could have been configured or restricted through the normal use of Holistics' services.
## 10. Free Services
**10.1 Definition of Free Services**: "Free Services" includes the Holistics Subscription Service offered on an unpaid trial, freemium, or open source offering basis.
**10.2 Disclaimer of Warranties**: Holistics provides the Free Services on an "as is" and "as available" basis without any warranties of any kind, either express or implied. Holistics expressly disclaims all warranties, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.
**10.3 Restrictions on Use**: Access to Free Services is intended solely for evaluating the potential purchase of a Holistics Service subscription. Use of the Free Services for business purposes is permitted; however, any use for competitive analysis, resale, or any form of commercialization of Holistics' services is strictly prohibited.
**10.4 Typical Use Limitations**: Customers agree not to use the Free Services in any manner that substantially exceeds typical use projections. This includes, but is not limited to, excessive storage and bandwidth consumption, to ensure fair access and resource allocation for all users of the Free Services.
**10.5 Support for Free Services**: If you do not pay a Subscription Fee, your support is available to you through the community pages of the Individual Products.
**10.6 Limitation of Liability**: Holistics shall not be liable for any indirect, incidental, special, consequential, or exemplary damages, including but not limited to, damages for loss of profits, goodwill, use, data, or other intangible losses, even if Holistics has been advised of the possibility of such damages, arising out of or in connection with the Free Services. In no event shall the total liability of Holistics for all damages, losses, and causes of action related to the Free Services exceed US$500 per year, regardless of the number of claims.
**10.7 Termination Rights**: Holistics reserves the right to suspend, limit, or terminate Free Services at any time without notice, including for reasons of inactivity, security concerns, or maintenance.
**10.8 Modification of Free Services**: Holistics reserves the right to modify or discontinue, temporarily or permanently, any or all of the Free Services with or without notice to the Customer(s). Holistics is not liable to the Customer(s) or to any third party for any modification, suspension, or discontinuance of the Free Services.
## 11. Account Information from Third-Party Providers
**11.1 Third-Party Information Retrieval**: Customers may direct Holistics to retrieve certain information maintained online by third-party providers with whom the customer has a customer-vendor relationship.
**11.2 Authorization for Access**: Holistics may require the customer to provide the login information necessary to access the customer's account with third-party providers with whom the customer has a customer relationship. By using the Service and providing Customer Access Information, customers expressly authorize Holistics to access and use their account information maintained by identified third parties, on their behalf as their agent.
**11.3 Customer Representations and Warranties**: Customers represent and warrant that neither the foregoing (nor anything else in this TOS) nor their use of the Services will violate any agreement or terms to which they are subject, including without limitation, those with respect to any third-party site.
**11.4 Agency Relationship and Liability Disclaimer**: Customers acknowledge and agree that when Holistics accesses and retrieves account information from third-party sites, Holistics acts as the customer's agent and not as the agent of or on behalf of the third party. As such, Holistics is not liable for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such third-party services.
**11.3 No Liability for Third-Party Services**: Holistics does not guarantee that any such third-party services will continue to be made available within the Service, and such services may be removed or disabled by Holistics at any time without notice to the customer. Customers acknowledge and agree that the Service may not be sponsored or endorsed by the third-party services accessible through the Service.
## 12. Limitation of Liability
**12.1 Exclusion of Certain Losses**: Neither party shall be liable to the other for any indirect, incidental, special, consequential, or punitive damages, including loss of profits, data, or use incurred by the other party, except as allowed under mandatory applicable law.
**12.2 Liability Cap**: Each party’s cumulative liability under this TOS in relation to liability arising from any given event or series of connected events shall be limited to the total amount paid by Customer in the twelve (12) months immediately preceding the month in which the event (or first in a series of connected events) occurred.
**12.3. Exclusion for Service Interruptions**: Holistics specifically excludes liability for any compensation, reimbursement, or damages arising from the Customer’s inability to use the services due to:
- **12.3.1**: Termination or suspension of this Agreement or the Customer's use of or access to the Service offerings;
- **12.3.2**: Discontinuation of any or all of the Service offerings by Holistics;
- **12.3.3**: Any unanticipated or unscheduled downtime of all or a portion of the services for any reason, including as a result of power outages, system failures, or other interruptions attributable to third-party hosting or infrastructure providers used by Holistics.
**12.4 Exclusion for Customer Mismanagement**: Holistics specifically excludes liability for any losses or damages arising from the Customer’s mismanagement of their use of the services. This includes, but is not limited to:
- **12.4.1**: Failure to follow adequate data security practices that prevent unauthorized access to their accounts;
- **12.4.2**: Incorrect or improper configuration of the service settings by the Customer;
- **12.4.3**: The Customer's provision of incorrect or incomplete information that is necessary for the proper provisioning and operation of the services;
- **12.4.4**: Unauthorized actions taken by the Customer or their agents that compromise the integrity or confidentiality of data processed through the services.
**12.5 Agreement to Liability Limit**: The liability limits herein are fundamental to the pricing of Holistics' services. By agreeing to these terms, the Customer acknowledges that accepting increased liability would require an adjustment to the pricing structure.
## 13. Governing Law and Dispute Resolution
**13.1 Governing Law**: This Agreement shall be governed by and construed in accordance with the laws of the Republic of Singapore, without regard to its conflict of law principles.
**13.2 Jurisdiction**: The parties irrevocably agree that the courts of Singapore shall have exclusive jurisdiction to settle any dispute or claim that arises out of or in connection with this Agreement or its subject matter or formation (including non-contractual disputes or claims).
**13.3: International Arbitration**: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre (“SIAC”) in accordance with the Arbitration Rules of the Singapore International Arbitration Centre (“SIAC Rules”) for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore. The Tribunal shall consist of a sole arbitrator. The language of the arbitration shall be English.
**13.4 Virtual Arbitration Proceedings**: Unless mutually agreed otherwise, all arbitration sessions shall be conducted virtually. If the Customer and Holistics cannot agree on an arbitrator, the respective arbitration institution will appoint an arbitrator experienced in the B2B SaaS or Enterprise software industry.
**13.5 Arbitration Award Enforcement**: The arbitration process will yield a binding award, which may be recognized and enforced by any court of competent jurisdiction, thus affirming the finality of the decision.
**13.6 Individual Capacity Only**: Both Customer and Holistics shall conduct any arbitration on an individual basis only, expressly waiving any right to initiate or participate in a class action or to seek relief on a class basis.
**13.7 Limitation on Arbitrable Remedies**: In line with the limitations in Section 12 (Limitation of Liability), the arbitrator is not authorized to award any indirect, special, incidental, or consequential damages, including but not limited to lost profits, arising from or related to this TOS.
---
## Terms of service (effective 24 December 2024)
:::warning Superseded version
This is the **24 December 2024** version of this document, retained for historical reference. It is no longer in effect. For the version that applies today, see [the current Terms of service](/legal/terms).
:::
:::tip Note
To download our Terms of Service, visit: https://go.holistics.io/terms. Click on **File >** download **Download As** then select your preferred format.
:::
_Last Updated: 24 December 2024 (from 3 October 2024)_
## 1. Definitions
**"Affiliate"** means any entity, whether now in existence or subsequently created, which directly or indirectly controls, is controlled by, or is under common control with a party to this Agreement. Control for the purposes of this definition is evidenced by direct or indirect ownership or control of more than 50% of the voting interests of the subject entity. Examples of such relationships include, but are not limited to, subsidiaries (entities controlled by a party), parents (entities that control a party), and siblings (entities under common control with a party).
**“Customer”, “you”, or “your”** refers to the person or entity using the Subscription Service and identified in the applicable account record, billing statement, online subscription process, or Subscription Agreement as the customer and your Affiliates in the scope of your purchase.
**“Customer Data”** refers to data that Holistics stores, processes, and/or secures for each Individual Product subscribed by the Customer as defined in the Individual Product Terms.
**"Embedded Solution"** refers to the incorporation, integration, or inclusion of any Holistics service or functionality from any Individual Product within a customer’s or third party's application, interface, or system in such a manner that it appears part of or operates seamlessly as part of the customer’s or third party's product offering.
**"Holistics", “we”, "us", or “our”** refers to Holistics Software Pte Ltd, a corporation incorporated under Singapore law, designated as a private company limited by shares.
**“Individual Product”** refers to one of the below software developed by Holistics.
- **“Holistics BI”** refers to the business intelligence software from Holistics (www.holistics.io).
- **“Holistics dbdiagram”** refers to the database diagramming software from Holistics (www.dbdiagram.io).
- **“Holistics dbdocs”** refers to the database documentation software from Holistics (www.dbdocs.io)
- **“Individual Product Terms”** refers to the terms for any one of the specific mentioned software in Holistics described in this TOS.
**"Party"** represents either Holistics or the Customer as a business entity.
**"Subscription Service" or "Service"** includes the software, professional services, and technical support services provided by Holistics listed under the Individual Product. This includes any updates, enhancements, new features, documentation, and educational content provided or made available to the Customer.
**"Subscription Agreement" or “Order Form”** means any agreement confirming the purchase of the Holistics Service, whether executed through in-app self-service processes, or via electronic signature of a Holistics Order Form. It becomes legally binding upon digital or electronic signing by an authorized representative of the Customer or by the completion of the subscription process within the Holistics application, each with the same legal force as a handwritten signature.
**“User” or "Users"** means employees, representatives, consultants, contractors, or agents authorized by the Customer to use the Subscription Service and have unique user identifications and passwords.
## 2. General Terms
**2.1 Acceptance of Terms**: By accepting this Terms of Service (TOS) or by accessing or using the Service, the Customer acknowledges that this TOS constitutes a legally binding agreement, enforceable in its electronic form. The Customer, as a legally constituted entity within its jurisdiction of formation, agrees to comply with all terms contained herein. The party entering into this Agreement is Holistics Software Pte Ltd, a corporation incorporated under the laws of Singapore and designated as a private company limited by shares, herein referred to as "Holistics." .
**2.2 Interpretation of Titles and Headings**: Titles and headings of sections of this TOS are for convenience only and shall not affect the construction of any provision of this Agreement.
**2.3 Entire Agreement and Order of Precedence**. This TOS is the entire agreement between Holistics and Customer regarding Customer’s use of Services and supersedes all prior and contemporaneous agreements, proposals or representations, written or oral, concerning its subject matter. The parties agree that any term or condition stated in a Customer purchase order or in any other Customer order documentation (excluding Subscription Agreements) is void. In the event of any conflict or inconsistency among the following documents, the order of precedence shall be:
- **2.3.1: The applicable Subscription Agreement(s)** - This is specific to the services purchased and contains terms tailored to the individual transaction. It overrides other documents for aspects specifically addressed therein.
- **2.3.2: Custom Terms of Service** - This document provides customized terms for certain customers based on specific agreements and will take precedence over the Standard TOS where applicable.
- **2.3.3: This Standard Terms of Service (TOS)** - Governs the general use of Holistics Services and applies to all customers unless superseded by more specific agreements as noted above.
- **2.3.4: The online documentation** of the applicable Holistics Individual Product(s) - This includes user manuals, product guidelines, and operational procedures that provide detailed information about the use and limitations of the services but do not override the legally binding terms found in the aforementioned documents.
**2.4 Relationship of the Parties**. This TOS does not create a partnership, franchise, joint venture, agency, fiduciary or employment relationship between the parties. Each party will be solely responsible for payment of all compensation owed to its employees, as well as all employment-related taxes.
**2.5 Anti-Corruption**. Neither party has received or been offered any illegal or improper bribe, kickback, payment, gift, or thing of value from an employee or agent of the other party in connection with this TOS. Reasonable gifts and entertainment provided in the ordinary course of business do not violate the above restriction
**2.6 Customer Compliance**: The Customer must comply with all applicable laws and regulations in their use of the Holistics Subscription Service and ensure all data provided or used is lawful and properly authorized. Additionally, Customers are prohibited from engaging in any unlawful activities including, but not limited to, unauthorized replication or modification of the service’s functionality, creation of derivative works, reverse engineering, and unauthorized access to source code. Violations of these provisions may lead to termination of service and legal action to protect Holistics' proprietary interests.
**2.7 Rights Reserved by Holistics**: Holistics' failure to enforce any provision of this TOS does not waive its right to do so in the future.
2.8 End-of-Support: Holistics reserves the right to discontinue support for features that have reached their end-of-support as communicated through official documentation and email announcements. Customers are advised to review these communications regularly to stay informed about which features are currently supported and any changes in support availability.
**2.9 Rights and Assignments**: The TOS cannot be assigned by the Customer without Holistics' consent, but Holistics may transfer this TOS with notice.
**2.10 Severability**. If any provision of this TOS is held by a court of competent jurisdiction to be contrary to law, the provision will be deemed null and void, and the remaining provisions of this Agreement will remain in effect.
**2.11 Amendments to TOS**: Holistics may revise these Terms from time to time to reflect changes in its services, laws, or regulatory requirements. If the Customer has an active Holistics subscription, Holistics will notify the Customer of any updates to the terms either via in-app notification or by email, provided that the Customer has opted to receive email updates.
## 3. Intellectual Property and Rights
**3.1 Intellectual Property Ownership**: Holistics retains all rights, titles, and interests, including intellectual property rights, in the Subscription Services. All rights not expressly granted to the Customer are reserved.
**3.2 Grant of Use**: Holistics grants the Customer a limited, worldwide, non-exclusive, non-transferable right to use the Subscription Service for internal business purposes, subject to compliance with this TOS. The Customer is entitled to access all functionality of the Subscription Service available as of the effective date stated in the Subscription Agreement.
**3.3 Metadata Rights**: Holistics may monitor Customer’s use of the Services and compile statistical and performance information in an aggregate and anonymous manner (“Metadata”), including to enhance service provision and operation. Holistics retains all intellectual property rights in such Metadata. Holistics shall ensure the use of Metadata respects the privacy and confidentiality agreements with the Customer and shall exclude personally identifiable information unless explicitly authorized. Customer agrees that Holistics may make the Metadata publicly available provided it does not incorporate any personally identifiable information or confidential Customer Data, nor does it identify Customer or its Confidential Information.
**3.4 Customer Feedback**: Holistics encourages feedback from all customers through our support communication channels. Customers agree that any feedback, suggestions, ideas, or other inputs provided to Holistics ("Feedback") will be considered non-confidential and non-proprietary. Holistics shall have a perpetual, irrevocable, worldwide, royalty-free license, including the right to sublicense, use, copy, modify, create derivative works from, and otherwise exploit any such Feedback for any purpose, without obligation or compensation to the provider. This Feedback may include, but is not limited to, improvements to Holistics' products, services, or processes.
**3.5 Publicity**: The Customer hereby grants Holistics the non-exclusive right to use the Customer's name and company logo in our customer list and on our website for marketing and promotional purposes. If the Customer wishes not to have their name and logo used in this manner, they may opt-out at any time by completing the Publicity Opt-Out Form available at [https://go.holistics.io/logo-opt-out](https://go.holistics.io/logo-opt-out).
## 4. Billing, Fees and Payment Terms
**4.1 Payment Obligation**: The Customer is committed to fulfilling all payment obligations for fees associated with the Subscription Service, as detailed in the Subscription Agreement. These fees are payable in advance, underscoring the Customer’s responsibility to ensure timely payment as part of their agreement with Holistics.
**4.2 Non-Refundable Fees**: Fees for the Subscription Service, as outlined in the Subscription Agreement, are non-refundable and non-cancelable, except where specifically stated in this Agreement. This clause affirms the binding nature of the payment commitment by the Customer for the agreed Subscription Term.
**4.3 Obligation to Maintain Current Billing Information**: Customers are required to keep their billing information up to date and accurate, including their credit card information for the payment of fees. This responsibility extends to all information necessary for the processing of payments, such as legal company name, address (especially state and country), and the primary contact.
**4.4 Credit Card Information Security**: Holistics does not store credit card details on our servers, ensuring customer payment information is secure. Billing is conducted through reputable third-party billing agents employing industry-standard security measures.
**4.5 Secure Transaction Processing**: Customers authorize Holistics to charge their credit card for all subscription fees during the Subscription Term. This process is securely managed through trusted third-party processors, with whom customers agree to share their payment information.
**4.6 Standard Payment Terms**: Payments are billed in advance via credit card and are due immediately upon billing. In the event of a failed credit card payment, Holistics reserves the right to retry billing the customer's credit card. This includes instances where the credit card has expired or is no longer valid. Holistics may automatically resume billing upon the customer updating their credit card information.
**4.7 Custom Payment Terms**: For eligible customers on annual billing plans that exceed a minimum subscription amount, Holistics offers custom payment terms including the option to pay via bank transfer. A deposit may be required, and administrative fees apply if conditions are not met.
**4.8 Responsibility for Bank Transfer Fees**: Customers opting for bank transfer payments must ensure that the net amount received by Holistics equals the invoiced amount, accounting for any fees charged by their bank for the international transfer. Holistics will cover any fees charged by its bank for receiving the funds.
**4.9 Customer Tax Responsibility**: All Subscription fees quoted or charged by Holistics are exclusive of government-imposed sales taxes, levies, duties, or similar governmental assessments of any nature, including but not limited to value-added, sales, use, or withholding taxes. Customers are responsible for paying all such taxes associated with their purchases under this agreement. If Holistics has the legal obligation to pay or collect any of these taxes for which the Customer is responsible, Holistics will invoice these to the Customer, and the Customer will pay that amount unless they provide Holistics with a valid tax exemption certificate authorized by the appropriate taxing authority. For clarity, Holistics is solely responsible for taxes assessable against it based on its income, property, and employees.
**4.10 Renewal Fee Adjustments**. Upon renewal, Holistics reserves the right to adjust the Subscription Fees payable by the Customer up to the then-current list price as detailed on the public pricing page of our Individual Products. Holistics will provide the Customer with a notice of any such fee adjustments at least thirty (30) days prior to the end of the Customer's then-current Subscription Term. These adjusted fees shall be effective commencing from the first day of the subsequent renewal term.
**4.11 Overdue Charges**: Late payments are subject to a monthly penalty of 1.5% of the overdue amount, calculated using the prevailing exchange rate from the date of default.
**4.12 Suspension for Non-Payment**: If the Customer's payment is overdue, Holistics will issue up to three reminders to the billing contact(s) specified in the applicable Individual Product Terms. These reminders may consist of notifications for pending bank transfer payments or failed payment retry attempts for other automated payment methods (credit card payments) as detailed in Section 4.6. If the overdue payment is not resolved following these reminders, Holistics reserves the right to suspend access to the Subscription Services. The specific timeline for initiating suspension due to non-payment, and the process for lifting such suspensions, is detailed in the Individual Product Terms in Section 6 and Section 7 of this TOS.
**4.13 Payment Disputes**: Holistics will not exercise its rights under the “Overdue Charges” or “Suspension for Non-Payment” of this section if Customer is disputing the applicable charges reasonably and in good faith and is cooperating diligently to resolve the dispute.
## 5. Term, Termination, and Suspension
**5.1 Duration and Renewal**. The initial term of the subscription shall commence as specified in the Subscription Agreement executed between the Customer and Holistics. Unless otherwise agreed in the said Subscription Agreement, the subscription shall automatically renew for the same term length or one year, whichever is shorter.
**5.2 Non-Renewal**. To prevent automatic renewal, the Customer must either cancel the subscription via the in-app billing page on their Holistics Individual Product’s application anytime before the renewal deadline or email a written notice of their intention not to renew at least three working days before the current subscription term ends. Detailed instructions for submitting a non-renewal notice or for early termination are available in the Individual Product Terms and the Holistics online documentation. It is the responsibility of the Customer to follow these procedures to ensure proper processing of their request
**5.3 Early Termination by Customer**. The Customer may terminate the subscription prior to the end of the term by providing thirty (30) days written notice. Upon such early termination, Holistics will not refund any prepaid fees or unused subscription fees. However, the Customer retains the right to continue using the Subscription Services until the end of the originally agreed term. The Customer remains obligated to settle any outstanding fees for the remaining subscription term.
**5.4 Termination for Cause**: This clause applies to any or all Subscription Services of the Individual Products under this TOS (including all related Subscription Agreements). Either party may terminate this Agreement for cause under the following conditions:
- **5.5.1 Material Breach**: Upon thirty (30) days' notice to the other party of a material breach if such breach remains uncured at the expiration of such period.
- **5.5.2 Insolvency and Bankruptcy**: Immediately, if the other party becomes the subject of a petition in bankruptcy or any proceeding relating to insolvency, cessation of business, liquidation, or assignment for the benefit of creditors, or if any such proceeding is instituted against such party (and not dismissed within sixty (60) days).
- **5.5.3 Cessation of Operations**: Immediately if the other party ceases its business operations without a successor.
- **5.5.4 Detrimental Conduct**: Upon thirty (30) days' written notice if one party reasonably determines that the other's conduct is damaging or could potentially damage the party’s reputation, business relationships, or operations. This includes, but is not limited to, engaging in illegal activities, fostering a hostile work environment, or other actions deemed significantly injurious to the other party's interests..
If the Customer terminates this Agreement for cause, Holistics will promptly refund any prepaid but unused fees covering the use of the Subscription Service after termination.
**5.6 Suspension for Cause**: Holistics may suspend the Customer's access to the Subscription Services, wholly or in part, under the following conditions:
- **5.6.1 Service Integrity**: If the Customer's use of the Subscription Services poses an immediate threat to the security, reliability, or integrity of the services, Holistics may suspend service access with immediate effect and will notify the Customer with the reason for suspension as soon as reasonably practicable.
- **5.6.2 Non-Payment**: For conditions leading to suspension due to non-payment, refer to the specific terms set out in Section 4.12. Suspension for non-payment will not apply if the Customer is disputing the overdue charges reasonably and in good faith. Upon resolution of the conditions leading to the suspension, Holistics will promptly restore the Customer's access to the services.
**5.7 Termination Beyond Suspension**: The right to suspend service does not limit Holistics' right to terminate the Agreement for cause as outlined in this section, particularly if the Customer's actions have, or may negatively reflect on or affect Holistics, its prospects, or its customers.
**5.8 Support Conduct Policy**: In the event of customer misuse or abuse of support services, or disrespectful conduct towards Holistics staff, Holistics reserves the right to limit or suspend support services to the offending customer. This policy is essential to maintaining a respectful and effective support environment and ensures that our support resources are used appropriately.
**5.9 Non-Exclusivity of Termination Remedies**: Termination is not an exclusive remedy, and the exercise by either party of any remedy under this TOS will be without prejudice to any other remedies it may have under this TOS, by law, or otherwise.
## 6. Individual Product Terms for Holistics Business Intelligence (BI)
**6.1 Customer Data for Holistics BI**:
**“Customer Data for Holistics BI”** shall encompass all data and information provided by or on behalf of the Customer in connection with their use of the Holistics Business Intelligence (BI) software. Customer Data is classified into the following categories:
- **6.1.1: “Customer Database” or “Customer Databases”** refers to any and all data records stored within the databases connected to the Holistics BI software by the Customer, and the database user credentials necessary for such connection. It is important to note that Holistics does not store, warehouse, or retain any raw physical data records contained within the Customer’s databases. Instead, Holistics queries the data directly from the Customer's databases in real-time when a report is loaded, ensuring data privacy and minimizing data exposure.
- **6.1.2. “Query Results Cache” or “Cache”** refers to the output results of SQL queries executed against the Customer’s Database. These results are temporarily cached within the Holistics system to enhance software performance and reduce the load on the Customer’s Database. Cached data is subject to automatic expiration after a customer-defined duration, with a minimum cache duration of ten minutes. Detailed information on the caching mechanism employed by Holistics is available at Holistics Data Caching Documentation.
- **6.1.3. “Application Metadata”** includes data pertaining to the usage of the Holistics software by the Customer, as well as descriptive information inputted by the Customer to label, contextualize, and define the logic of data within the Holistics platform. This category encompasses, without limitation, report titles, column and formatting settings, data field formulas, logic, labels, analytics modeling definitions, and data delivery recipients. Application Metadata serves to facilitate the organization, interpretation, and application of analytics definitions to facilitate self-service analytics.
**6.2 Data Center Locations**: Holistics BI operates globally, with data centers in the US, Europe, and Asia. Customers can select the data center where their databases will be processed, in compliance with local data residency laws. More information on our data center locations is available at [https://docs.holistics.io/docs/data-centers](https://docs.holistics.io/docs/data-centers).
**6.3 Support Response Times**: Holistics BI responds to most support tickets within 2 business days for standard non-critical issues, often responding even quicker. More details can be found at [https://docs.holistics.io/docs/customer-support/support-sla](https://docs.holistics.io/docs/customer-support/support-sla)
**6.4 System Uptime**: Holistics BI targets for our system availability and uptime to be 99.5%. Real-time status of our system up-time can be found at [https://status.holistics.io](https://status.holistics.io)
**6.5 Support Impersonation**: Our support team may request permission to enable impersonation in-app for troubleshooting support tickets effectively. Users can disable this permission in-app at any time, and access in-app activity monitoring for transparency. Details on support impersonation are available at [https://docs.holistics.io/docs/customer-support/support-impersonation](https://docs.holistics.io/docs/customer-support/support-impersonation)
**6.6 Data Retention and Deletion**: Upon termination of the Subscription Service due to subscription cancellation or the expiration of a trial period, Holistics will retain the Customer's data for a period of 180 days, or upon the customer’s request, whichever is earlier. Following this period, the data will be automatically removed from the system. This retention period allows Customers the opportunity to reactivate their subscription or retrieve their data before permanent deletion. Details on Data Retention can be found at [https://docs.holistics.io/docs/data-retention-period](https://docs.holistics.io/docs/data-retention-period)
**6.7 Suspension for Non-Payment**: If payment for Holistics BI is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 30 days, access to the Service may be suspended.
**6.8 Legacy Products**: For Customers using legacy products from Holistics BI, it is essential to recognize that access to the full suite of features available in newer versions may be limited. Holistics enables Customers to verify their product version in-app within each Individual’s Product. Should a Customer determine they are using a legacy version and wish to upgrade, executing a new Subscription Agreement with Holistics is required to facilitate the transition to the most current product version.
## 7. Individual Product Terms for Holistics dbdiagram and dbdocs.
**7.1 Customer Data Definitions**: “Customer Data” shall encompass all data and information provided by or on behalf of the Customer and is classified into the following categories:
- **7.1.1 “Customer Data for dbdiagram”** refers to the Entity Relationship Diagrams metadata stored on dbdiagram software by the Customer, including the information necessary for defining the database structure and visualizing the entity relationship diagram.
- **7.1.2 “Customer Data for dbdocs”** refers to the Database Documentation metadata stored on dbdocs software by the Customer, including the information necessary for defining the database structure and visualizing the database documentation.
- **7.1.3 Customer Data is only Metadata only**: For avoidance of doubt, both dbdiagram and dbdocs contain only metadata, and do not connect, store, and/or contain any live database records or credentials
**7.2 Data Center Location**: dbdiagram and dbdocs are hosted with a reputable data center provider in Singapore, Asia.
**7.3 Support Response Times**: Response time during support hours is typically within 2 business days, and more details can be found at [https://dbdiagram.io/docs/support-sla](https://dbdiagram.io/docs/support-sla)
**7.4 System Uptime**: dbdocs and dbdiagram target our system availability and uptime to be 99.5%. The real-time status of our system up-time can be found at [https://status.dbdiagram.io](https://status.dbdiagram.io)
**7.5 Data Retention and Deletion**: Customer Data is retained permanently unless users submit a request to remove their account and all data related to them [https://dbdiagram.io/docs/faqs/remove-account](https://dbdiagram.io/docs/faqs/remove-account)
**7.6 Suspension for Non-Payment**: If payment for dbdiagram and/or dbdocs is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 9 days, access to the Service may be suspended.
## 8. Confidentiality
**8.1 Confidential Information**: “Confidential Information” means all information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether orally or in writing, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and the circumstances of disclosure. For avoidance of doubt,
- **8.1.1: Confidential Information of Customer** refers to the Customer Data as mentioned in the Individual Product Terms.
- **8.1.2: Confidential Information of Holistics** refers to the Services of the Individual Product(s) subscribed by the Customer, the terms and conditions of this Agreement, custom pricing plans, and all exceptional commercial arrangements Holistics made for the Customer and are not available to the majority of other Customers.
- **8.1.3: Mutual Confidential Information** includes business and marketing plans, technology and technical information, product plans and designs, and business processes disclosed by such party.
**8.2 Confidential Information Exclusions**: Confidential Information does not include any information of the below categories:
- **8.2.1 Public Knowledge**: Information that is or becomes generally known to the public without breach of any obligation owed to the Disclosing Party,
- **8.2.2 Prior Knowledge**: Information was known to the Receiving Party prior to its disclosure by the Disclosing Party without breach of any obligation owed to the Disclosing Party,
- **8.2.3 Third-Party Information**: Information that is received from a third party without knowledge of any breach of any obligation owed to the Disclosing Party, or
- **8.2.4 Independent Development**: Information that was independently developed by the Receiving Party.
**8.3 Application of Confidentiality Obligations to Additional Services Evaluation**: For the avoidance of doubt, the non-disclosure obligations set forth in this “Confidentiality” section apply to Confidential Information exchanged between the parties in connection with the evaluation of each Individual Product.
**8.4 Protection of Confidential Information**: Each party agrees to take reasonable measures, at least substantially equivalent to the measures it takes to protect its own confidential information, to protect the confidentiality and avoid the unauthorized use, disclosure, publication, or dissemination of the other party’s Confidential Information.
**8.5 Data Minimization in Communications**: Customers are required to limit the sharing or exposure of sensitive or confidential data within communications, including support tickets. Information shared should be strictly necessary and relevant for the resolution of inquiries or issues. This practice of data minimization is crucial to protect sensitive information and minimize the risk of unauthorized data exposure.
**8.6. Customer Data Protection**: Holistics is dedicated to the security and confidentiality of Customer Data defined in our Individual Product Terms. We employ robust technical and organizational measures to protect against unauthorized access and loss. This commitment extends to maintaining commercially appropriate administrative, physical, and technical safeguards, as detailed in our Data Processing Agreement (DPA), including the Security Measures outlined in Annex 2 of the DPA. Further, we process Customer Data with a rigorously evaluated and approved list of sub-processors, specified in the DPA, chosen for their compliance with applicable data protection laws and standards, ensuring the highest level of security and confidentiality.
**8.7 Notification of Breach**: In the event of any breach of confidentiality or a data breach affecting Confidential Data, the Receiving Party shall promptly notify the Disclosing Party without unreasonable delay, considering the severity and volume of the breach in line with applicable regulations. The notification will include details of the breach, the steps taken to address it, efforts to regain possession of Confidential Information, and prevent its further unauthorized use, and any actions that affected parties should consider to protect themselves.
**8.8 Supersession of Prior NDAs**: The confidentiality obligations outlined in Section 8 of this TOS shall apply to all Confidential Information disclosed between the parties, including disclosures made prior to the effective date of this TOS. This TOS supersedes any previously executed non-disclosure agreements (NDAs) or confidentiality agreements between the parties, and all such agreements are hereby terminated in their entirety.
## 9. Warranty and Indemnity
**9.1 Service Warranty**: Holistics warrants that the Subscription Service will be performed in a manner consistent with generally accepted industry standards. This warranty does not apply to Free Services.
**9.2 Correction for Non-Conformance**: If the Subscription Service does not conform to the above warranty, Holistics shall correct any material reproducible impairments to the features and functionality of the Service so that it materially conforms to the applicable warranty within a commercially reasonable time following receipt of written notice of breach.
**9.3 Limitations**: Holistics is not liable under this warranty section if the non-conformance is caused by
- (i) combining the Subscription Service with any hardware, software, equipment, or data not supplied by Holistics,
- (ii) any modification of the Subscription Service by any party other than Holistics, or modifications made by Holistics per specifications or instructions provided by the Customer,
- (iii) use of the Subscription Service in violation of or outside the scope of this Agreement, or
- (iv) interruptions to the Subscription Service, including but not limited to outages, that are beyond its reasonable control, such as internet disruptions, cyber-attacks, or hardware failures. Efforts to mitigate such interruptions shall be undertaken promptly, but reparation for downtime or data loss will not exceed the pro-rata service fees paid by the Customer for the duration of the interruption.
**9.4 Exclusive Remedy**: If Holistics is unable to correct the non-conformity within sixty (60) days from when you notify us of the issue ("Remedy Period"), then either party may terminate this TOS by providing the other party written notice within thirty (30) days after the end of the Remedy Period. Upon such termination for non-conformance from the Customer, Holistics will refund any prepaid but unused fees covering use of the Subscription Service after termination.
**9.5 Disclaimer of Warrantie**: Holistics provides the Subscription Service "as is" and does not make any representations or warranties regarding its suitability, reliability, availability, timeliness, security, accuracy, or completeness. This includes all implied warranties or conditions of merchantability, fitness for a particular purpose, title, and non-infringement. Application Programming Interfaces (APIs) and other features may not be available at all times and are subject to maintenance and updates.
Holistics does not warrant that the Subscription Service will be uninterrupted or error-free; use of the service may be affected by numerous factors outside our control. Except as expressly stated in Section 9.1 "Service Warranty," these exclusions apply to the fullest extent permitted by law.
**9.6 Indemnification**: Customer will defend, indemnify, and hold harmless Holistics, its affiliates, officers, directors, employees, agents, suppliers, licensors, and assigns from and against any claims, actions, proceedings, losses, damages, expenses, and costs (including but not limited to court costs and reasonable attorney fees) arising out of or in connection with:
- **9.6.1 Unauthorized Use**: Unauthorized or illegal use of Holistics' Services, noncompliance with this Agreement, or actions exceeding the scope of services as permitted under this Agreement.
- **9.6.2 Third-Party Integrations**: Integration or use of Holistics' services with non-Holistics applications, data sources, or configurations that were not provided or explicitly approved by Holistics, including claims arising from third-party components that are part of the ecosystem but not endorsed or provided by Holistics.
- **9.6.3 Unauthorized Access**: Unauthorized access to Holistics' services through the Customer’s information or infrastructure, regardless of whether the Customer had knowledge of such access.
- **9.6.4 Modifications Without Consent**: Modifications to Holistics' services by the Customer or by third parties engaged by the Customer without Holistics' prior written consent, particularly if such modifications lead to the claims asserted.
- **9.6.5 Misconfiguration and Administrative Errors**: Misconfiguration of user permissions or security settings by the Customer, which could have been configured or restricted through the normal use of Holistics' services.
## 10. Free Services
**10.1 Definition of Free Services**: "Free Services" includes the Holistics Subscription Service offered on an unpaid trial, freemium, or open source offering basis.
**10.2 Disclaimer of Warranties**: Holistics provides the Free Services on an "as is" and "as available" basis without any warranties of any kind, either express or implied. Holistics expressly disclaims all warranties, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.
**10.3 Restrictions on Use**: Access to Free Services is intended solely for evaluating the potential purchase of a Holistics Service subscription. Use of the Free Services for business purposes is permitted; however, any use for competitive analysis, resale, or any form of commercialization of Holistics' services is strictly prohibited.
**10.4 Typical Use Limitations**: Customers agree not to use the Free Services in any manner that substantially exceeds typical use projections. This includes, but is not limited to, excessive storage and bandwidth consumption, to ensure fair access and resource allocation for all users of the Free Services.
**10.5 Support for Free Services**: If you do not pay a Subscription Fee, your support is available to you through the community pages of the Individual Products.
**10.6 Limitation of Liability**: Holistics shall not be liable for any indirect, incidental, special, consequential, or exemplary damages, including but not limited to, damages for loss of profits, goodwill, use, data, or other intangible losses, even if Holistics has been advised of the possibility of such damages, arising out of or in connection with the Free Services. In no event shall the total liability of Holistics for all damages, losses, and causes of action related to the Free Services exceed US$500 per year, regardless of the number of claims.
**10.7 Termination Rights**: Holistics reserves the right to suspend, limit, or terminate Free Services at any time without notice, including for reasons of inactivity, security concerns, or maintenance.
**10.8 Modification of Free Services**: Holistics reserves the right to modify or discontinue, temporarily or permanently, any or all of the Free Services with or without notice to the Customer(s). Holistics is not liable to the Customer(s) or to any third party for any modification, suspension, or discontinuance of the Free Services.
## 11. Account Information from Third-Party Providers
**11.1 Third-Party Information Retrieval**: Customers may direct Holistics to retrieve certain information maintained online by third-party providers with whom the customer has a customer-vendor relationship.
**11.2 Authorization for Access**: Holistics may require the customer to provide the login information necessary to access the customer's account with third-party providers with whom the customer has a customer relationship. By using the Service and providing Customer Access Information, customers expressly authorize Holistics to access and use their account information maintained by identified third parties, on their behalf as their agent.
**11.3 Customer Representations and Warranties**: Customers represent and warrant that neither the foregoing (nor anything else in this TOS) nor their use of the Services will violate any agreement or terms to which they are subject, including without limitation, those with respect to any third-party site.
**11.4 Agency Relationship and Liability Disclaimer**: Customers acknowledge and agree that when Holistics accesses and retrieves account information from third-party sites, Holistics acts as the customer's agent and not as the agent of or on behalf of the third party. As such, Holistics is not liable for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such third-party services.
**11.3 No Liability for Third-Party Services**: Holistics does not guarantee that any such third-party services will continue to be made available within the Service, and such services may be removed or disabled by Holistics at any time without notice to the customer. Customers acknowledge and agree that the Service may not be sponsored or endorsed by the third-party services accessible through the Service.
## 12. Limitation of Liability
**12.1 Exclusion of Certain Losses**: Neither party shall be liable to the other for any indirect, incidental, special, consequential, or punitive damages, including loss of profits, data, or use incurred by the other party, except as allowed under mandatory applicable law.
**12.2 Liability Cap**: Each party’s cumulative liability under this TOS in relation to liability arising from any given event or series of connected events shall be limited to the total amount paid by Customer in the twelve (12) months immediately preceding the month in which the event (or first in a series of connected events) occurred.
**12.3. Exclusion for Service Interruptions**: Holistics specifically excludes liability for any compensation, reimbursement, or damages arising from the Customer’s inability to use the services due to:
- **12.3.1**: Termination or suspension of this Agreement or the Customer's use of or access to the Service offerings;
- **12.3.2**: Discontinuation of any or all of the Service offerings by Holistics;
- **12.3.3**: Any unanticipated or unscheduled downtime of all or a portion of the services for any reason, including as a result of power outages, system failures, or other interruptions attributable to third-party hosting or infrastructure providers used by Holistics.
**12.4 Exclusion for Customer Mismanagement**: Holistics specifically excludes liability for any losses or damages arising from the Customer’s mismanagement of their use of the services. This includes, but is not limited to:
- **12.4.1**: Failure to follow adequate data security practices that prevent unauthorized access to their accounts;
- **12.4.2**: Incorrect or improper configuration of the service settings by the Customer;
- **12.4.3**: The Customer's provision of incorrect or incomplete information that is necessary for the proper provisioning and operation of the services;
- **12.4.4**: Unauthorized actions taken by the Customer or their agents that compromise the integrity or confidentiality of data processed through the services.
**12.5 Agreement to Liability Limit**: The liability limits herein are fundamental to the pricing of Holistics' services. By agreeing to these terms, the Customer acknowledges that accepting increased liability would require an adjustment to the pricing structure.
## 13. Governing Law and Dispute Resolution
**13.1 Governing Law**: This Agreement shall be governed by and construed in accordance with the laws of the Republic of Singapore, without regard to its conflict of law principles.
**13.2 Jurisdiction**: The parties irrevocably agree that the courts of Singapore shall have exclusive jurisdiction to settle any dispute or claim that arises out of or in connection with this Agreement or its subject matter or formation (including non-contractual disputes or claims).
**13.3: International Arbitration**: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre (“SIAC”) in accordance with the Arbitration Rules of the Singapore International Arbitration Centre (“SIAC Rules”) for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore. The Tribunal shall consist of a sole arbitrator. The language of the arbitration shall be English.
**13.4 Virtual Arbitration Proceedings**: Unless mutually agreed otherwise, all arbitration sessions shall be conducted virtually. If the Customer and Holistics cannot agree on an arbitrator, the respective arbitration institution will appoint an arbitrator experienced in the B2B SaaS or Enterprise software industry.
**13.5 Arbitration Award Enforcement**: The arbitration process will yield a binding award, which may be recognized and enforced by any court of competent jurisdiction, thus affirming the finality of the decision.
**13.6 Individual Capacity Only**: Both Customer and Holistics shall conduct any arbitration on an individual basis only, expressly waiving any right to initiate or participate in a class action or to seek relief on a class basis.
**13.7 Limitation on Arbitrable Remedies**: In line with the limitations in Section 12 (Limitation of Liability), the arbitrator is not authorized to award any indirect, special, incidental, or consequential damages, including but not limited to lost profits, arising from or related to this TOS.
---
## Data processing agreement (DPA)
:::tip Where to sign this document
Sign the Holistics Data Processing Agreement at: https://go.holistics.io/signdpa
Unfortunately, we are unable to review or sign DPAs provided by our customers, and customized DPAs are only available on a case-by-case basis for customers on our custom Enterprise Plans and may come with additional costs.
:::
_Last Updated: 13 June 2026_
**Definitions**
"California Personal Information" means Personal Data that is subject to the protection of the CCPA.
"CCPA" means California Civil Code Sec. 1798.100 et seq. (also known as the California Consumer Privacy Act of 2018).
"Consumer", "Business", "Sell" and "Service Provider" shall have the meanings given to them in the CCPA.
"Customer" refers to the Customer on a paid subscription plan with Holistics as described in the Terms, and all of its Affiliates.
"Customer Data" or "Customer Database" refers to all data residing in the Customer's database(s) and data source(s) connected to Holistics by Customer.
Customer End Users means the employees of the Customer who have been invited to access the Holistics Subscription Service in their customer account, or are in contact with Holistics.
"Data Protection Laws" means all applicable worldwide legislation relating to data protection and privacy which applies to the respective party in the role of Processing Personal Data in question under the Agreement, including without limitation European Data Protection Laws (EU and UK GDPR), the US CCPA, the Swiss FDPA, the Singapore PDPA, and the data protection and privacy laws of Australia; in each case as amended, repealed, consolidated or replaced from time to time.
"Data Subject" means the individual to whom "Personal Data" relates.
"Database Metadata" refers to the following categories of metadata from the customers' database which includes broadly (but not limited to):
User credentials of data source(s), applied with the necessary security encryption before storing in Holistics database.
The metadata (e.g. names of schemas, tables, fields, model relationships descriptions) of the database tables, excluding physical data record entries.
The metadata of definitions of objects created within the Holistics application (dashboards, data sets, data models, automated schedules).
Any other metadata that may be added from time to time.
"Europe" means the European Union, the European Economic Area and/or their member states, Switzerland, and the United Kingdom.
"European Data" means Personal Data that is subject to the protection of European Data Protection Laws.
"European Data Protection Laws" means data protection laws applicable in Europe, including:
Regulation 2016/679 of the European Parliament and of the Council (General Data Protection Regulation, "GDPR");
Directive 2002/58/EC concerning the processing of personal data and the protection of privacy in the electronic communications sector;
Applicable national implementations of (i) and (ii);
UK GDPR as it forms part of UK domestic law by virtue of Section 3 of the European Union (Withdrawal) Act 2018;
Swiss Federal Data Protection Act of 19 June 1992 and its Ordinance (“Swiss DPA”), as may be amended, superseded, or replaced.
"Instruction" means the written instruction, issued by Customer to Holistics, and directing the same to perform a specific action with regard to the Customer Database (including, but not limited to, depersonalising, blocking, deletion, making available). Instructions shall initially be specified in the Terms and may, from time to time thereafter, be amended, amplified or replaced by Customer in separate written instructions (individual instructions).
"PDPA" refers to the Personal Data Protection Act 2012 legislated in Singapore.
"Personal Data" means the personal data contained within the Customer Database, including any special categories of personal data defined under the Data Protection Laws of each jurisdiction, in each case processed by Holistics under the Terms.
"Personal Data Breach" means a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, Personal Data transmitted, stored or otherwise Processed by Holistics and/or its Sub-Processors in connection with the provision of the Subscription Services. "Personal Data Breach" shall not include unsuccessful attempts or activities that do not compromise the security of Personal Data, including unsuccessful log-in attempts, pings, port scans, denial of service attacks, and other network attacks on firewalls or networked systems.
"Process" or "Processing" means any operation or set of operations which is performed on Personal Data, encompassing the collection, recording, organization, structuring, storage, adaptation or alteration, retrieval, consultation, use, disclosure by transmission, dissemination or otherwise making available, alignment or combination, restriction or erasure of Personal Data.
"SCCs" means the Customer SCCs and/or SCCs as applicable, including:
Module 2: From a controller based in Europe to a processor (C2P)
Module 3: From a processor based in Europe to a processor (P2P)
UK SCC: From a controller based in UK to a Processor
"Sub-Processor" means any Processor engaged by Holistics or its Affiliates to assist in fulfilling the obligations with respect to the provision of the Subscription Services under the Agreement. Sub-Processors may include third parties or Affiliates but will exclude any Holistics employee or consultant.
"Temporary Cached Query Results" refer to all results provided to Customer, Customer End Users, or for System Consumption (APIs) for queries executed against Customer Database via Holistics for technical and performance reasons. These results are cached temporarily and will automatically expire after a specific time (minimum 10 minutes) after a unique SQL query is executed from the Customer Database.
"Terms" refers to the Terms of Service at https://www.holistics.io/terms.
### Introduction
This Data Processing Agreement ("DPA") reflects the parties' agreement with respect to the terms governing the Processing of data in the Customer Database under the Holistics Customer Terms of Service ("Terms"), and supersedes any previously signed DPA on an earlier date.
The DPA is an addon to, and forms an integral part of the Terms. It is effective upon its incorporation into the Terms, an online self-service purchase, or an Order or an executed amendment to the Agreement.
The terms "personal data", "data subject", "processing", "controller" and "processor" used in this DPA have the meanings given in the GDPR irrespective of whether European Data Protection Law or Non-European Data Protection Law applies.
The terms "Personal Data", "Customer Data", and "Customer Database" may be used interchangeably in this DPA.
This DPA shall follow the term of the Terms, including but not restricted to the Terms clauses
"Account Information from Third Party Providers"
"Limitation of Liability" and
"Indemnification" clauses.
In case of any conflict or inconsistency with the Terms, this DPA will take precedence to the extent of such conflict or inconsistency
The duration of Processing shall be the same as the duration of the Terms and this DPA.
The clauses of this DPA shall follow the Terms. Definitions not otherwise defined above herein shall have the meaning as set forth in the Terms.
### Holistics' Responsibilities
Holistics will only Process Customer Database for the purposes described in this DPA or as otherwise agreed within the scope of the Customer's Instructions, except where and to the extent otherwise required by applicable law.
Holistics will only access or use Customer Database to provide the Services ordered by Customer and will not use it for any other Holistics products, services, advertising, or to resell the data.
Where Customer enables Holistics' AI-powered features, Holistics will transmit Customer Data to the AI (LLM) Sub-Processors listed in Annex 3 only to the extent necessary to provide those features and in accordance with the data-sharing controls the Customer configures in its AI settings. Holistics will not use Customer Data, and will contractually require that such Sub-Processors do not use Customer Data, to train or improve any machine-learning or AI models. Further detail on the data accessed by each AI feature and the controls available to Customer is maintained at https://docs.holistics.io/docs/ai/data-access-and-policy.
Holistics is not responsible for compliance with any Data Protection Laws applicable to the Customer's industry that are not applicable to us.
Holistics shall email the customer if we become aware of a confirmed breach and also further
Take any such reasonably necessary measures and actions to remedy or mitigate the effects of the Breach and
Keep the Customer informed of all material developments in connection with the Breach.
Provide reasonable information and cooperation so that the Customer can fulfill any data breach reporting obligations it may have under (and in accordance with the timescales required by) the applicable Data Protection law.
If any such request, correspondence, enquiry or complaint is made directly to the Holistics, Holistics will promptly inform the Customer providing full details of the same.
Holistics will take the appropriate technical and organisational measures (listed in Annex 2) to adequately protect Customer Database against misuse and loss in accordance with the requirements of the applicable national data protection law. Such measures hereunder shall include, but not be limited to,
the prevention of unauthorised persons from gaining access to Customer Database (physical access control),
the prevention of Customer Database from being accessed without authorisation (logical access control),
ensuring that Customer Database cannot be read, copied, modified or deleted without authorisation during electronic transmission and Holistics Software instance. (data transfer control),
Have a reasonable audit trail system in place to document whether and by whom information on Customer Database has been entered into, modified in, or removed from Customer Database (entry control),
ensuring that data from Customer Database are processed solely in accordance with the Instructions (control of instructions),
persons authorised to process the personal data have committed themselves to confidentiality or are under an appropriate statutory obligation of confidentiality,
Holistics Data Protection Team will provide prompt and reasonable assistance with any Customer queries related to processing of Customer Personal Data under the Agreement and can be contacted at %%CONTACT%%.
### Customer Responsibilities
Customer is responsible for complying with all applicable Data Protection Laws with respect to its Processing of Personal Data in the Customer Database connected to Holistics.
Customer shall retain title to their Customer Database connected to the Holistics Software instance and take technical safeguards to provision (and not over-provision) the appropriate level of data source connection for the user credentials supplied to Holistics.
Customer shall be solely responsible for
the accuracy, quality, and legality of Customer Database and the means in which Personal Data is acquired;
complying with all necessary transparency and lawfulness requirements under applicable Data Protection Laws for the collection and use of the Personal Data, including obtaining any necessary consents and authorizations (particularly for use by Customer for marketing purposes);
complying with the statutory requirements relating to data protection, in particular regarding safeguards against unauthorized access to Customer Database from Holistics software systems.
Customer shall inform Holistics without undue delay and comprehensively about any errors or irregularities related to statutory provisions on the Processing of Customer Database detected during a verification of the results of such Processing.
Customer is responsible for security relating to its environment and databases and security relating its configuration of the Software. This includes implementing and managing procedural, technical, and administrative safeguards on its software and networks sufficient to:
ensure the confidentiality, security, integrity, and privacy of Customer Database in transit, at rest, and in storage;
protect against any anticipated threats or hazards to the security and integrity of Customer Database; and
protect against any unauthorized processing, loss, use, disclosure or acquisition of or access to Customer Database.
Customer will minimize the sharing of Personal Data of Data Subjects in the support tickets and emails information sent to Holistics.
If such Personal Data needs to be included for troubleshooting, the Customer will deliberately add specific Instructions to handle such email communications.
For the avoidance of doubt, emails sent by the Customer with generic company email content confidentiality boilerplates appended by default will not be classified as confidential information.
Notwithstanding any other provision of this DPA, the Terms or any other agreement related to the Software and Services, Holistics has no obligations or liability as to any breach or loss resulting from:
The Customer's environment, databases, systems or software, or
The Customer's security configuration or administration of the Software.
Customer is solely responsible for provisioning Users on the Software, including:
methods of authenticating Users (such as industry-standard secure username/password policies, two-factor authentication etc);
Restricting access by User or group, and from the database level down to the row or column level;
Managing admin privileges;
deauthorizing personnel who no longer need access to the Software;
setting up any API usage in a secure way; and
regularly auditing any public access links Users create and restricting the permission to create public links, as necessary.
Customer is responsible to remove the network connection between Customer Database and the Holistics Software Instance should they terminate the Subscription Service.
Customer may choose to connect or enable integrations with third-party services using the Customer's own accounts or credentials (for example, messaging, spreadsheet, source control, or data transformation tools). Such third-party services are not Holistics sub-processors; the Customer's use of them is governed by the Customer's own agreement with the relevant provider, and Holistics transmits data to them solely on the Customer's Instruction.
### Customer Database Sub-Processors
Customer consents to Holistics engaging affiliates and third party sub-processors to process data in Customer Database for the purpose as described in the Terms.
Holistics will maintain an up-to-date list of its sub-processors. For avoidance of doubt, the above consent constitutes Customer's prior written consent to the sub-Processing by Holistics (Annex 3)
Holistics will impose data protection terms on any sub-processor it appoints as required to protect Customer Data to the standard required by the Data Protection Laws.
If Holistics intends to instruct sub-Processors other than the companies listed in Annex 3, Holistics will notify the Customer thereof in writing (email to the email address(es) on record in Processor's account information for Customer is sufficient) and will give the Customer the opportunity to object to the engagement of the new sub-Processors within 30 days after being notified.
The objection, if raised, must be based on reasonable grounds (e.g. if the Customer proves that significant risks for the protection of its Customer Data exist at the sub-Processor).
In such an event, Holistics will either not appoint or replace the sub-processor or, if this is not possible, Customer may suspend or terminate the Terms (without prejudice to any fees incurred by Customer prior to suspension or termination).
**Data Transfers**
Customer acknowledges and agrees that Holistics may access and process Customer Data on a global basis as necessary to provide the Subscription Service in accordance with the Agreement, and in particular that Customer Data may be transferred to the data centre location(s) that Holistics operates in.
Holistics may store and process (i) Holistics Metadata and Usage Data and (ii) Temporary Cached Query Results anywhere Holistics or its Sub-processors maintain facilities, subject to Sections on Additional Provisions for European Data, Additional Provisions for California Personal Information, or other jurisdictions where Holistics operates in.
The physical data records residing in Customer Database will not be stored permanently by Holistics application servers outside of the purpose set in the Terms.
Temporary Cached Query Results needed to visualize the dashboard data will be temporarily stored in Holistics, and will automatically expire after a specific time duration.
Wherever Personal Data is transferred outside its country of origin, each party will ensure such transfers are made in compliance with the requirements of Data Protection Laws.
### Provisions Specific for European Data
The parties acknowledge and agree that European Data Protection Law will apply to the processing of Customer Data if:
The processing is carried out in the context of the activities of an establishment of Customer in the territory of the EEA or the UK; and/or
Customer Personal Data is personal data relating to data subjects who are in the EEA or the UK and the processing relates to the offering to them of goods or services in the EEA or the UK, or the monitoring of their behavior in the EEA or the UK.
Definitions:
"Controller" means the natural or legal person, public authority, agency or other body which, alone or jointly with others, determines the purposes and means of the processing of Personal Data.
"Processor" means a natural or legal person, public authority, agency or other body which processes Personal Data on behalf of the Controller.
Relationship between Customer and Holistics:
Holistics is the Processor of the Customer Database for the purposes described in the Terms. Holistics is the Processor regardless of whether Customer is itself a controller or a processor of that data; the distinction below determines only which Module of the EU SCCs applies, not Holistics' role.
The EU SCCs (Annex 4) incorporate both Modules, and the applicable Module is determined automatically by Customer's role with respect to the relevant data (no separate election is required):
where, and to the extent that, Customer is the Controller of data (which may include Personal Data and Data Subjects) stored in the Customer Database, SCC Module 2 (Controller to Processor) applies;
where, and to the extent that, Customer is the Processor of data stored in the Customer Database (for example, where Customer embeds the Subscription Services and processes its own customers' data on their behalf), SCC Module 3 (Processor to Processor) applies.
Where Customer's role is mixed, each Module applies to the corresponding processing.
Holistics and the Customer shall be separately responsible for conforming with such statutory data protection regulations as are applicable to them.
Legacy MCCs: The SCCs will, as of the Transition Date, supersede and terminate any Model Contract Clauses approved under Directive 95/46/EC and previously entered into by Customer and Holistics.
The Transition Date means:
October 27, 2021, if (a) Customer’s billing address is outside EMEA, and (b) the processing of Customer Personal Data is subject to European Data Protection Law.
Otherwise, September 27, 2021.
Data Protection Impact Assessments and Consultation with Supervisory Authorities: Holistics will (taking into account the nature of the processing and the information available to Holistics) assist Customer in ensuring compliance with its (or, where Customer is a processor, the relevant controller's) obligations under Articles 35 and 36 of the GDPR, by:
Providing and updating our public documentation on technical security measures (see: /docs/security-compliance/data-security);
Providing public documentation on how Holistics caching and job queuing mechanism work (see: /docs/performance/data-caching);
Providing the Security Measures (Annex 2) contained in the Agreement including these Terms; and
if the above subsections are insufficient for Customer (or the relevant controller) to comply with such obligations, upon Customer's request, providing Customer with additional reasonable cooperation and assistance.
Transfer Mechanism for Data Transfers:
Permitted Transfers: The parties acknowledge that European Data Protection Law does not require SCCs or an Alternative Transfer Solution in order for Customer Personal Data to be processed in or transferred to an Adequate Country ("Permitted Transfers").
Restricted Transfers: If the processing of Customer Personal Data is not processed in an Adequate Country, and European Data Protection Law applies to those transfers, then
The EU SCCs (EU Controller-to-Processor) will apply with respect to Restricted Transfers between Customer and Holistics that are subject to the EU GDPR and/or the Swiss FDPA; and
the UK SCCs (UK Controller-to-Processor) will apply (regardless of whether Customer is a controller and/or processor) with respect to Restricted Transfers between Customer and Holistics that are subject to the UK GDPR.
Holistics agrees to abide by and process European Data in compliance with the Standard Contractual Clauses.
Although Holistics does not rely on the Singapore Personal Data Protection Act 2012 ("PDPA") as a legal basis for transfers of Personal Data, Holistics will inform Customer if it is unable to comply with this requirement if any conflicts arise.
The parties agree that for the purposes of the Standard Contractual Clauses:
Holistics will be the "data importer" and Customer will be the "data exporter" (on behalf of itself and Permitted Affiliates);
the Annexes of the Standard Contractual Clauses shall be populated with the relevant information set out in Annex 1 and Annex 2 of this DPA;
if and to the extent the Standard Contractual Clauses conflict with any provision of this DPA, the Standard Contractual Clauses will prevail to the extent of such conflict.
To the extent that and for so long as the Standard Contractual Clauses as implemented in accordance with this DPA cannot be relied on by the parties to lawfully transfer Personal Data in compliance with the GDPR, the applicable standard data protection clauses issued, adopted or permitted under the GDPR shall be incorporated by reference, and the annexes, appendices or tables of such clauses shall be deemed populated with the relevant information set out in Annex 1 and Annex 2 of this DPA.
Demonstration of Compliance:
Holistics will make all information reasonably necessary to demonstrate compliance with this DPA available to Customer and allow for and contribute to audits, including inspections conducted by or an auditor appointed by Customer in order to assess compliance with this DPA.
Customer acknowledges and agrees to exercise audit rights under this DPA and Clause 8 of the Standard Contractual Clauses by instructing Holistics to comply with the audit measures described in this 'Demonstration of Compliance' section.
Customer acknowledges that the Subscription Service is hosted by our data center partners (listed in our sub-processors) who maintain independently validated security programs.
Holistics may charge a fee (based on Holistics' reasonable costs) for any audit under Demonstration of Compliance. Holistics will provide the Customer with further details of any applicable fee, and the basis of its calculation, in advance of any such audit. Customer will be responsible for any fees charged by any auditor appointed by Customer to execute any such audit.
Holistics may object in writing to an auditor appointed by Customer to conduct any audit under Demonstration of Compliance if the auditor is, in Holistics' reasonable opinion, not suitably qualified or independent, a competitor of Holistics, or otherwise manifestly unsuitable. Any such objection by Holistics will require the Customer to appoint another auditor or conduct the audit itself.
Processing Records: Holistics will keep appropriate documentation of its processing activities. To the extent the GDPR requires Holistics to collect and maintain records of certain information relating to Customer, Customer will, where requested, supply such information to Holistics and keep it accurate and up-to-date. Holistics may make any such information available to the Supervisory Authorities if required by the GDPR.
No Modification of SCCs: Nothing in the Agreement (including these Terms) is intended to modify or contradict any SCCs or prejudice the fundamental rights or freedoms of data subjects under European Data Protection Law.
### Provisions Specific for California Personal Information
This section will apply only with respect to California Personal Information residing in Customer Database.
When processing California Personal Information in accordance with Customer's Instructions, the parties acknowledge and agree that Customer is a Business and Holistics is a Service Provider for the purposes of the CCPA.
Both parties agree that Holistics will Process California Personal Information as a Service Provider strictly for the purpose of performing the Subscription Services or as otherwise permitted by the CCPA, including as described in our Terms.
### Limitation of Liability
Each party's liability, taken together in the aggregate, arising out of or related to this DPA, and all DPAs between Customer and Holistics, whether in contract, tort or under any other theory of liability, is subject to the 'Limitation of Liability' section of the Terms, and any reference in such section to the liability of a party means the aggregate liability of that party under the Agreement and all DPAs together.
For the avoidance of doubt, Holistics' total liability for all claims from the Customer arising out of or related to the Agreement and each DPA shall apply in the aggregate for all claims under both the Agreement and all DPAs established under the Agreement by the Customer.
### Governing Law and Disputes
This DPA will be governed by and construed in accordance with the laws of the Singapore, unless otherwise required by
EU Data Protection Law, in which case this DPA will be governed by the laws of the Member State in which the Customer is established.
CCPA, in which case this DPA will be governed by the laws of California, USA.
the Data Protection Laws of each jurisdiction the Customer operates in
If Holistics becomes aware that Customer Data cannot be processed in accordance with the Customer's Instructions due to a legal requirement under any applicable law, Holistics will
promptly notify Customer that legal requirement to the extent permitted by the applicable law; and
where necessary, cease all Processing (other than merely storing and maintaining the security of the affected Customer Data) until such time as the Customer issues new Instructions with which Holistics is able to comply. If this provision is invoked, Holistics will not be liable to the Customer under the Agreement for any failure to perform the applicable Subscription Services until such time as Customer issues new lawful Instructions with regard to the Processing.
Arb-Med-Arb: Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of the arbitration shall be Singapore.
The Tribunal shall consist of one (1) arbitrator(s)
The language of the arbitration shall be English
### Core documents and annexes
These documents always form part of this DPA:
- This Data Processing Agreement (DPA), as defined in [https://docs.holistics.io/legal/dpa](https://docs.holistics.io/legal/dpa)
- Holistics Terms of Service (Terms), as defined in [https://holistics.io/terms](https://holistics.io/terms)
- [Annex 1: Subject Matter and Details of Data Processing](/legal/annex-subject-matter)
- [Annex 2: Security Measures](/legal/annex-security-measures) (Technical and Organisational Measures to ensure the security of the data)
- [Annex 3: List of Holistics Sub-Processors](/legal/annex-sub-processors)
### Selective annexes
These annexes apply to the Customer where relevant:
- [Annex 4: EU Standard Contractual Clauses](/legal/annex-eu-scc) (incorporating Module 2 (Controller to Processor) and Module 3 (Processor to Processor); the applicable Module is determined by Customer's role)
- [Annex 5: UK SCC (Controller to Processor)](/legal/annex-uk-scc)
%%SIGNATORY%%
---
## GDPR statement
_Updated: 12 June 2026_
Holistics is built to help you meet your obligations under the EU and UK General Data Protection Regulation (GDPR). This page explains the role we play when we process data on your behalf, where that data is hosted, and the contractual and technical safeguards we provide. If you need anything further for a security or vendor review, [contact us](https://www.holistics.io/contact-us/).
## Our role under the GDPR
The GDPR splits responsibility between the **controller** (who decides why and how personal data is processed) and the **processor** (who processes it on the controller's instructions). Holistics plays both roles, depending on the data:
- For the data you connect to Holistics (your **Customer Database**), you are the controller — or a processor acting for your own customers — and **Holistics acts as your processor**. We handle that data only on your documented instructions, as set out in our [Data Processing Agreement](data-processing-agreement.md).
- For the account and usage data we collect to run the service (such as the details of the users you invite), **Holistics is the controller**. How we collect, use, and share that data, and how individuals can exercise their data-subject rights, is described in our [Privacy Policy](privacy-policy.md).
GDPR compliance is therefore shared: you remain responsible for the lawful basis and content of the data you connect, and we are responsible for processing it securely and only as instructed.
## Where your data is hosted
You choose the region your workspace runs in when you sign up, and that determines where Holistics stores your metadata and temporary cached query results:
- **EU** — Frankfurt, Germany
- **Asia-Pacific** — Singapore
- **United States** — San Francisco
Customers with EU data-residency requirements can keep their workspace in the Frankfurt (EU) region. See [Data Centers](/docs/security-compliance/data-centers) for the full details, including hosting providers and regional endpoints.
## Data processing agreement
You can read the [Data Processing Agreement (DPA)](data-processing-agreement.md) in full. The DPA sets out our obligations as a processor of the data you connect to Holistics, and it incorporates the annexes below as part of the agreement.
### International data transfers
When we transfer European personal data to a country that doesn't have an adequacy decision, we rely on the Standard Contractual Clauses (SCCs) approved by the European Commission and the UK. These are attached to the DPA as annexes, and the one that applies depends on your role:
- [Annex 4: EU Standard Contractual Clauses](annex-eu-scc.md). This single annex incorporates both EU SCC Module 2 (Controller to Processor) and Module 3 (Processor to Processor). The module that applies is determined automatically by your role: Module 2 where you are the controller of the data, and Module 3 where you are a processor acting on behalf of your own customers (as is common with embedded analytics).
- [Annex 5: UK SCC (Controller to Processor)](annex-uk-scc.md), for transfers subject to the UK GDPR.
### Security measures
The technical and organisational measures we maintain to protect your data (as required by Article 32 of the GDPR) are set out in [Annex 2: Security Measures](annex-security-measures.md). For the scope of what we process and why, see [Annex 1: Subject Matter and Details of Data Processing](annex-subject-matter.md).
---
## Non-disclosure agreement (NDA)
:::tip Where to sign this document
Sign the Holistics Non-Disclosure Agreement at: https://go.holistics.io/signnda
Please note that we are unable to review or sign NDAs provided by our customers, or customize our NDA on a case-by-case basis.
:::
_Last Updated: 3 July 2026_
## Mutual Non-Disclosure Agreement
This Agreement is dated **[Date]** and made
**BETWEEN**
Holistics Software Private Limited, whose principal place of business is located at 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545 and its Affiliates ("Holistics")
**AND**
**[Company Name]**, whose principal place of business is located at **[Company Address]**, and its Affiliates (the "Evaluator").
(each a "Party" and collectively "Parties")
"Affiliates" shall be defined as any company controlled by, controlling, or under common control with Holistics or the Company respectively.
**WITNESSETH:**
The Evaluator intends to evaluate one or more of Holistics' software product lines for potential purchase and use (the "Pre-Purchase Evaluation Purpose").
In connection with this evaluation, the Evaluator and Holistics may disclose to each other certain confidential and proprietary information, which will be used solely for the Pre-Purchase Evaluation Purpose. The parties agree to protect such confidential information from unauthorised use and disclosure under the terms of this Agreement.
In consideration of the disclosure of such information by each of the parties, Holistics and the Evaluator agree as follows:
## 1. Definitions
### 1.1 Evaluator Confidential Information by Product
The following table outlines the types of Confidential Information that may be shared by the Evaluator in the pre-purchase evaluation process, specific to each Holistics product line:
| Product/Description | Evaluator Confidential Information |
| --- | --- |
| **Holistics BI** — A web-based software product for business intelligence and data analytics ([www.holistics.io](https://www.holistics.io)). | **Customer Database:** All data records stored within the Evaluator's databases connected to the Holistics BI platform. **Database Metadata:** Schemas, tables, fields, relationships, logic definitions, and data model configurations created or used within Holistics BI. **Query Results Cache:** SQL outputs temporarily stored for optimization during evaluation. |
| **Holistics dbdiagram** — A web-based tool for creating database diagrams and visualizing entity relationships ([www.dbdiagram.io](https://www.dbdiagram.io)). | **Entity Relationship Diagrams:** Metadata for defining database structure and visualizing relationships between entities. **Diagram Metadata:** Titles, formatting, labels, and logic used to contextualize or represent database structure in diagrams. |
| **Holistics dbdocs** — A tool for documenting databases, including schema structures, table descriptions, and related metadata ([www.dbdocs.io](https://www.dbdocs.io)). | **Database Documentation Metadata:** Metadata for defining database structures, including field descriptions, tables, and relationships. **Application Metadata:** Descriptive information used for labeling and contextualizing data within dbdocs, e.g., column formatting or explanatory notes. |
### 1.2 Holistics Confidential Information
Confidential Information provided by Holistics may include, but is not limited to:
- **Custom Proposals and Pricing:** Custom pricing models, bundled offerings, and non-standard commercial terms that are not published on the latest version of the individual product's pricing page.
- **Internal Product Development:** Product architecture, APIs, integrations, security measures, and other operational strategies unique to Holistics.
### 1.3 Mutual Confidential Information
The following categories of information are considered mutually confidential when disclosed by either party:
- **Non-Public Product Roadmap:** Plans for future releases, updates, and beta features not yet publicly announced.
- **Technical Documentation:** Any shared documentation related to APIs, integrations, workflows, or internal tools.
- **Strategic Insights:** Go-to-market plans, competitive positioning, or joint business strategies disclosed as part of the evaluation.
- **Internal Policies:** Processes, support strategies, or internal training materials provided for collaborative understanding.
For the avoidance of doubt, emails sent by the Customer with generic company email content confidentiality boilerplates appended by default will not be classified as confidential information.
## 2. Limitation on Disclosure
The Evaluator and Holistics agree:
- to hold the other party's and all mutual Confidential Information in confidence,
- not to disclose such Confidential Information to any third parties, except its independent contractors as reasonably necessary to carry out the Pre-Purchase Evaluation Purpose and who must be under an obligation of confidentiality not less restrictive than provided under this Agreement, and informed of the confidentiality of the Confidential Information,
- not to use any Confidential Information for any purpose except for the Pre-Purchase Evaluation Purpose,
- not to use any of the other party's Confidential Information for its own commercial advantage or to the commercial advantage of any third party; or to the commercial disadvantage of the other party, and
- to limit the sharing or exposure of sensitive or confidential data within communications, including support tickets. Information shared should be strictly necessary and relevant for the resolution of inquiries or issues. This practice of data minimization is crucial to protect sensitive information and minimize the risk of unauthorized data exposure.
## 3. Exempting Provisions
Confidential Information will not include information:
- that is now, or hereafter becomes, through no act or failure to act on the part of the receiving party, generally known or available to the public;
- was acquired by the receiving party before receipt of such information from the disclosing party and without restriction as to use or disclosure;
- is hereafter rightfully furnished to the receiving party by a third party without restriction as to use or disclosure;
- is independently developed by the receiving party without use of or reference to the disclosing party's Confidential Information; or
- is disclosed with the prior written consent of the disclosing party.
## 4. Compelled Disclosure
This Agreement shall not prohibit either party from disclosing information to the extent reasonably required by law; provided, that the party required to disclose such information shall provide, to the extent legally permissible, prior notice to the disclosing party of such required disclosure and the opportunity to obtain an appropriate protective or other court order.
## 5. Return of Confidential Information
Upon either disclosing party's request, the receiving party will promptly return to such disclosing party all tangible items containing or consisting of such disclosing party's Confidential Information and all copies thereof, and/or to the extent Confidential Information is held in non-tangible form (e.g., electronic copies), all such Confidential Information shall be permanently destroyed.
If so requested by the disclosing party, the receiving party shall promptly certify to the disclosing party the return of all tangible Confidential Information and destruction of all non-tangible Confidential Information.
Notwithstanding anything herein to the contrary, (a) one (1) copy of all of the Confidential Information may be retained by counsel to the receiving party for evidentiary purposes and (b) the receiving party will not be required to delete or destroy any back-up tapes that capture Confidential Information which cannot be reasonably deleted or destroyed; provided that any Confidential Information retained by the receiving party as described in this sentence will remain subject to this Agreement for so long as such Confidential Information is retained.
## 6. Ownership
Holistics and the Evaluator acknowledge that all Evaluator Confidential Information is owned solely by the Evaluator disclosing it and/or its licensors, and all Holistics Confidential Information is owned solely by Holistics and/or its licensors.
Notwithstanding the foregoing, provided that Confidential Information is not used in violation of this Agreement, nothing herein shall be construed as a representation or inference that either party has not or will not develop information, material, technology or products for itself or others, that is similar to information, material, technology or products disclosed to its hereunder or that competes with products of the other party.
Holistics and the Evaluator recognise and agree that nothing contained in this Agreement will be construed as granting any rights to Holistics or the Evaluator, by license or otherwise, to any Confidential Information disclosed to it by the other party except as specified in this Agreement.
## 7. Limitations on Use
Neither Holistics nor the Evaluator shall attempt to reverse compile, reverse assemble, or reverse engineer any of the other party's software, equipment or other code that are within the meaning of Confidential Information or authorise others to do any of the foregoing.
## 8. Governing Law
This Agreement will be construed, interpreted, and applied in accordance with the laws of Singapore. This Agreement is the complete and exclusive statement regarding the subject matter of this Agreement.
In the event of any breach of this Agreement, in addition to all other rights and remedies available to the non-breaching party, the non-breaching party shall be entitled to seek an injunction to remedy such breach.
## 9. Dispute Resolution and Arbitration
Any dispute arising out of or in connection with this Agreement, including any question regarding its existence, validity, or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre ("SIAC") in accordance with the Arbitration Rules of the Singapore International Arbitration Centre ("SIAC Rules") for the time being in force, which rules are deemed to be incorporated by reference in this clause.
The seat of arbitration shall be Singapore. The Tribunal shall consist of a sole arbitrator. The language of the arbitration shall be English.
Unless mutually agreed otherwise, all arbitration sessions and proceedings shall be conducted virtually. If the Evaluator and Holistics cannot agree on an arbitrator, the Singapore International Arbitration Centre shall appoint an arbitrator with expertise in the B2B SaaS or Enterprise Software industry.
## 10. Assignment
Neither party may assign all or any portion of its rights or obligations under this Agreement to any third party without the prior written consent of the other party to this Agreement, which consent shall not be unreasonably withheld.
## 11. Modification and Waiver
No modification or waiver of any of the terms of this Agreement shall be valid unless in writing and executed with the same formality as this Agreement.
The failure of either party to insist on strict compliance with any of the terms, covenants or conditions of this Agreement by the other party shall not be deemed a waiver of that or any other term, covenant or condition, nor shall any waiver or relinquishment of any right or power at any time be deemed a waiver or relinquishment of that right or power for all or any other times.
## 12. Export Control
Holistics and the Evaluator recognise that the communication or transfer of any information received pursuant to the Pre-Purchase Evaluation Purpose may be subject to specific governmental export approval.
Holistics and the Evaluator agree to comply with all applicable export control legislation.
## 13. Term & Duration
This Agreement will remain in effect unless either party gives 6 weeks written notice of termination. Sections 3, 4, 5, 6, 7, 8, 9, and 10 shall survive any termination of this Agreement indefinitely, and Section 2 shall survive the termination of this Agreement for five (5) years.
## 14. No Obligation
Nothing contained herein shall be construed as obligating either party to disclose any Confidential Information or enter into any subsequent agreement or transact any business with the other party.
## 15. No License
The receiving party recognizes and agrees that nothing contained in this Agreement shall be construed as granting any rights, by license or otherwise, to any Confidential Information, or as obligating either party hereto to enter into any further agreement with respect to the subject matter hereof or otherwise.
## 16. Entire Agreement
This Agreement contains the entire understanding of Holistics and the Evaluator with respect to the matters provided for herein and supersedes any and all other prior agreements, covenants, arrangements, communications, representations or warranties, whether oral or in writing, by any of the parties or by any officer, employee or representative of any party with respect to such matters.
If the Evaluator subscribes to Holistics' services, this Agreement shall be superseded by the confidentiality provisions of the Terms of Service ("TOS") agreed to during subscription. The parties agree that the TOS will govern all subsequent disclosures of confidential information.
For clarity, any information shared before subscription will remain subject to this Agreement until superseded by the TOS.
---
**IN WITNESS WHEREOF**, the parties hereto have executed this Agreement by their duly authorised officers or representatives.
Signed for and on behalf ofHOLISTICS SOFTWARE PTE. LTD.
Signed for and on behalf of[EVALUATOR]
By: ……………………………………………
By: ……………………………………………
Name:
Name:
Title:
Title:
Date:
Date:
---
## Privacy policy
:::info Shareable link
This page is also available at the short, stable URL **https://holistics.io/privacy**.
:::
_Updated: 13 June 2026_
Holistics understands you care how information about you is collected and used. Holistics is committed to protecting the privacy of individuals who interact with us. This Holistics Privacy Policy (“Privacy Policy”) describes Holistics Software Pte Ltd and its affiliated entities (collectively “Holistics”) privacy practices for our websites, services, and apps that link to this policy. This policy also details our privacy practices related to Holistics marketing, advertising, and company events.
In this Privacy Policy, we will refer to the Holistics Data Platform and related services collectively as our "Services." We will refer to our emails, newsletters, and other marketing and advertising practices collectively as our “Communications.” For the purpose of this Privacy Policy, “Personal Information” means any information relating to an identified or identifiable natural person.
[Description of Users and Acceptance of Terms](#description-of-users-and-acceptance-of-terms)
[Information Collected by Holistics](#information-collected-by-holistics)
[Contact Information](#contact-information)
[Billing Information](#billing-information)
[Information Tracking Technologies](#information-tracking-technologies)
[Trial Sign-Ups](#trial-sign-ups)
[Information from Support and Success Channels](#information-from-support-and-success-channels)
[Call recordings and meeting notes](#call-recordings-and-meeting-notes)
[Shared collaboration channels](#shared-collaboration-channels)
[Information Collected from Third Parties](#information-collected-from-third-parties)
[Information Stored on Holistics](#information-stored-on-holistics)
[As a Controller of Personal Information](#as-a-controller-of-personal-information)
[As a Processor of Personal Information](#as-a-processor-of-personal-information)
[How We Use Personal Information](#how-we-use-personal-information)
[Information security and storage](#information-security-and-storage)
[Cross-border data transfers](#cross-border-data-transfers)
[How Long Do We Retain Your Personal Information?](#how-long-do-we-retain-your-personal-information)
[Reviewing, updating or deleting your Personal Information](#reviewing-updating-or-deleting-your-personal-information)
[Opting-out of Holistics Communications](#opting-out-of-holistics-communications)
[Minimum Age](#minimum-age)
[For More Information](#for-more-information)
## Description of Users and Acceptance of Terms
This Privacy Policy applies to visitors to the Site ([“www.holistics.io”](https://www.holistics.io)), who view only publicly-available content (the “Visitors”) and subscribers (the “Subscribers”) who have signed up to access and use our platform (the “Platform”).
By visiting our Site, Visitors are agreeing to the terms of this Privacy Policy and the accompanying Website Terms of Service. By signing up, accessing, and/or using the Platform, each Subscriber is agreeing to the terms of this Privacy Policy and the accompanying Terms of Service (“TOS”)
## Information Collected by Holistics
### Contact Information
When you contact us through the “Contact Us” page, or sign up to become a Subscriber, you will be asked to provide certain information which may include First name, Last name, Email address, Job title, Company name, Country and mobile phone number (“Contact Information”). The Contact Information is used to provide the requested Service or information, and to contact subscribers and visitors for purposes of direct marketing of our current and future Services.
We retain Contact Information to send product updates, relevant marketing, training and events based on the users’ communication preferences.
### Billing Information
In order to purchase a subscription to our Platform, you will be required to provide certain additional information which may include a credit card number, expiration date, billing zip code, activation code, and similar information (“Billing Information”).
Billing Information is collected and processed by our third-party payment processor operating as our agent. Holistics does not directly store, obtain or process any Billing Information within our own application.
### Information Tracking Technologies
We receive and store certain information about how you use our websites and Services when you visit them through the use of Information Tracking Technologies (“ITT”), which include first-party and third-party cookies, logs, web beacons, and other similar technologies. Our servers collect similar information when you are logged into our website or Services.
The information we receive through ITT may be associated with you, depending on the website or Services you are using, and whether you have provided information identifying yourself to the website or Services.
Cookies are identifiers we transfer to your browser or device that allow us to recognize your browser or device and tell us how and when pages and features in our Services are visited and by how many people.
For example, we receive information that your browser or device sends to our servers whenever you visit a Holistics website. Your browser or device may tell us your internet protocol (IP) address used to connect your computer to the Internet, computer and connection information such as browser type, version, language and time zone settings, browser plug-in types, operating system, and type of device you are using.
When you visit our Site, your browser may also tell us information such as the actions you take on our Site, the page that led you to our Site and, if applicable, the search terms you typed into a search engine that led you to our Site.
You may be able to change the preferences on your browser or device to prevent or limit your device’s acceptance of cookies, but this may prevent you from taking advantage of some of our features. We may use this data to customize content for you that we think you might like, based on your usage patterns, and generally to improve the Services.
We use ITT to collect information about your interactions with the Site (“Usage Data”) and how the Site is performing (“Analytics Data”). Usage Data may include information regarding any interaction you have with the Site, such as which functionalities are used and the frequency of use (e.g., pages visited, actions taken, queries run, user accounts, account roles, and connected database types). Analytics Data may include query response times, application response times and other metrics that monitor the responsiveness of the Site.
In addition, third parties may be able to collect information about your online activities when you use our websites or Services from ITT. We do not respond to web browser ‘do not track’ signals or other similar transmissions that indicate a request to disable online tracking of users who visit our websites or who use our websites or Services.
If you receive emails from us, we may use certain analytics tools, to capture data such as when (and where) you opened our email or click on any links or banners our email contains. This data helps us to gauge the effectiveness of our communications and marketing campaigns.
### Trial Sign-Ups
When you sign up for a trial, we collect your work email and rejects the use of common personal publicly available email domain accounts. We will send product, marketing and sales related emails designed to help facilitate your product evaluation.
Holistics use third party tools to help you onboard effectively, such as setting up user onboarding flows, automate pop-up message, or to study in-app activity of trial users to identify and clear obstacles for them to have a successful onboarding experience.
### Information from Support and Success Channels
Information you provide through our support and success channels will be stored. The Services also include our customer support and customer success, where you may choose to submit information regarding a problem you are experiencing with a Service.
Whether you designate yourself as a technical contact, open a support ticket, speak to one of our representatives directly or otherwise engage with our support team, you will be asked to provide contact information, a summary of the problem you are experiencing, and any other documentation, screenshots or information that would be helpful in resolving the issue.
From time to time, our support team may temporarily access your tenant for the sole purpose of prompt resolution and fast troubleshooting. All such access attempts are logged internally and associated with a support ticket.
Information sent to our group emails for support and customer success will be accessible by our engineering and business teams on duty to provide you a more holistic and responsive service level. If any of such data needs to be shared, please indicate in your email communications that such data is sensitive and confidential.
For the avoidance of doubt, emails sent with generic company confidentiality boilerplates footers at all emails appended by default will not be classified as such sensitive/confidential information unless otherwise stated.
### Call recordings and meeting notes
We record and transcribe video calls and meetings with customers and prospects, and we generate written notes and summaries from them. These recordings, transcripts, and notes are accessible across our company so that our teams can learn from your product feedback and pain points, recognize common use cases that help us prioritize our roadmap, and build a stronger relationship with you over time. We may also analyze this content, including with the help of AI tools, to share insights across our teams, support training and coaching, and improve our products and Services. They also help us understand your context so you do not have to repeat yourself from one conversation to the next.
We use third-party meeting and note-taking tools to capture and process this content, and we store the resulting recordings and transcripts with our cloud storage providers. We retain them only for as long as they remain useful for the purposes above, in line with the principles described in the "How Long Do We Retain Your Personal Information?" section below.
### Shared collaboration channels
To make working together easier, we may set up shared communication channels with you, such as a Slack channel using a guest account that Holistics sponsors, or a shared Slack Connect channel between our two organizations. These channels give both companies visibility into the messages and files shared within them, which speeds up collaboration and gives you a more direct line to our team. Any information you share in these channels is subject to this Privacy Policy as well as the terms of the underlying platform (for example, Slack).
### Information Collected from Third Parties
We maintain pages on online social networks and advertising sites. We may collect information when you interact with our social network pages.
We advertise online, including displaying Holistics ads across the Internet on websites and in apps. When we advertise online to you, we may collect information about which ads are displayed to you, which ads you click on, and the web page where the ad was displayed to you.
## Information Stored on Holistics
We act as both a Controller and Processor of Information Stored for our customers.
### As a Controller of Personal Information
We store data on Holistics customers and visitors on our Site and Service. This includes details such as trial sign-up information, login details (encrypted), as well as metadata about product usage. Metadata is used to facilitate product improvements, customer support and license auditing.
### As a Processor of Personal Information
Where our Customer Database (described in our terms of service) is connected to Holistics, and the Customer Database contains Personal Information necessary to answer queries from Holistics users, Holisics acts as a processor of Personal Information.
Holistics does not store or sync a copy of your data directly from source, so your data remains stored on your own central servers.
You can provide read-only connection to access the minimum amount of data needed to answer your questions in your query results. You may choose to provide Holistics write-access to your database for our ETL features, where Holistics will help you move data across your data sources, or run in-database transformation to speed up query performance.
You may also leverage on the Holistics cache, which speeds up your dashboard/report access time by preloading your query results at scheduled intervals of your choosing. This cache is set to automatically expire at your preferred duration, and can be turned off on a per report basis. You can choose to remove the cache data at any time for your query.
## How We Use Personal Information
In relation to any Personal Information obtained from Google, please refer to the designated [Google User Data](#google-user-data) section outlined below. For all other scenarios, Holistics may employ your Personal Information in order to:
* Provide you with Holistics website content, the features, functions and benefits of the Service.
* Respond to your requests for information, products, or services, and to provide customer service and support.
* Operate, maintain and improve our websites and Services (such as, for the purposes of fixing malfunctions, testing our security systems, etc.)
* Provide you with notices related to your use of the Service.
* Personalize our website, Services, and Communications to your likely interests and
needs
* Send you business messages such as those related to Services notifications,
payments or renewal of your subscription, or process billing for your Service subscription (i.e. charging your credit card).
* Provide you with promotional and marketing emails. You can opt-out of receiving certain types of promotional and marketing emails, but if you do you may not receive the full benefit of the Service. Opting-out can be done by following the instructions at the bottom of the promotional material.
* Contact you via telephone to discuss our Services and related offers with you
* Personalize the Service experience for you (such as, remembering your information
so you will not have to enter it each time you use the Service).
* Enhance, improve and further develop the Service (such as, creating new features or
functions, refining the user experience, increasing Service technical performance,
etc.).
* Email you periodically (intervals of months) to to explore if you like to re-evaluate our Services if you have previously tried us.
* Enable advertising delivered to you to be more relevant.
* Analyze our communications and interactions with you (including with the help of AI tools) to understand your needs, enable our customer-facing teams, and improve our Services and communications.
* For other purposes about which we notify you.
From time to time, we may provide information to our customers and potential customers in the form of electronic or print newsletters. When you subscribe to our newsletter you may be added to our mailing list and will receive announcements and information about Holistics. It will be emailed or mailed to the address that you provide when you subscribe.
Holistics’ third-party service providers serve ads on our behalf across the Internet. Some of these ads may be personalized for you based on information collected from your use of the Site.
### Google User Data
* Holistics restricts its data access exclusively to the files you supply within the application configuration. A cached version of these files may be retained. We refrain from downloading or storing any files from your Google account that were not expressly provided within the application.
* Holistics accesses and reads data from files (including CSV, Sheet, and Excel formats) on your Google Drive, as an integral component of our Data Import feature. Subsequently, we process this data and import it into the data warehouse you have configured. Furthermore, we may generate new files or alter existing ones for the purpose of exporting data via our Scheduled Deliveries feature, which transfers data to Google Sheets.
* We do not share or sell any of your files with any third parties.
* We reserve the right to review the data you provide solely as required for the upkeep, provision, and enhancement of the Service, or to address a support request initiated by you. Additionally, we may examine this data in the aggregate and on an anonymous basis to gain insights into the usage patterns of Holistics.
### How we use AI to learn from our interactions with you
We analyze the communications and interactions we have with customers and prospects, including emails, support and success conversations, community and forum contributions, feedback, and the recordings, transcripts, and notes from calls and meetings, to share insights across our customer-facing teams, support internal training and enablement, and improve our products, Services, and communications.
To help us do this, we use third-party AI and machine-learning service providers to summarize and analyze this content. We share only the information reasonably necessary for this purpose, and we do not permit these providers to use your information to train their own models.
This applies to our own relationship and communications data. It does not extend to the data within your connected Customer Database, which we process solely as described in the "As a Processor of Personal Information" section and under your instructions.
## Personal Information Sharing
In certain circumstances, Holistics may share your Personal Information with third-party service providers that provide the below services for Holisics:
* Email communications (operational, marketing).
* Customer Relationship Management.
* Data management.
* Database hosting.
* Payment card processing.
* Helpdesk services.
* Shipping services.
* Collaboration services (including shared communication channels).
* Cloud services.
* Meeting scheduling, recording, and transcription services.
* Survey and feedback services.
* Artificial intelligence and machine-learning services (used to help us analyze and summarize our communications and interactions with you). We do not permit these providers to use your information to train their models.
* Online Advertisements. For Google User data, we do not transfer or disclose or sell your information to third parties services providers that provide Online Advertisements service.
We will only pass your data on to third parties without your express consent if we are obliged to do so by statutory law or an instruction by a public authority or court.
## Information security and storage
We understand that the security of your Personal Information is important. We implement reasonable administrative, technical, and physical security controls designed to protect your Personal Information from loss, misuse, unauthorized access, disclosure, alteration, or destruction. However, despite our efforts, no security controls are completely effective and we cannot ensure or warrant the security of your Personal Information.
Your Personal Information and data files are stored on our servers and the servers of our affiliated companies and companies we hire to provide services to us.
In respect to any data originating from Google's platforms that Holistics may access, utilize, or retain, please be advised that we implement suitable security measures commensurate with the nature of said data. Such protections include, but are not limited to, cryptographic encryption and other procedural safeguards designed to preserve the confidentiality and integrity of your information.
## Cross-border data transfers
Your Personal Information may be stored in Singapore, the United States, where our employees reside, or transferred to other countries where the companies we hire to help us run our business are located. Those countries may not have the same data protection laws as the country in which you initially provided the information. When we transfer your Personal Information, we will protect it as described in this privacy policy.
## How Long Do We Retain Your Personal Information?
We will retain personal information for as long as it is needed for legitimate business purposes to fulfil the purposes we collected it for, including for the purposes of satisfying any legal, accounting, or reporting requirements. We keep such servers to help protect the stability and availability of the Service (such as protecting it from viruses and malfunctions).
To determine the appropriate retention period for personal information, we consider the amount, nature, and sensitivity of the personal information, the potential risk of harm from unauthorised use or disclosure of your personal information, the purposes for which we process your personal information and whether we can achieve those purposes through other means, and the applicable legal requirements.
Some of your Personal Information may also remain on backup systems or third-party services after your use of our websites and/or Services ends, unless you request that your data be deleted.
Details of retention periods for different aspects of your personal information are available in our retention policy which you can request from us by contacting us. See the **“Reviewing, updating or deleting your Personal Information”** section below.
## Reviewing, updating or deleting your Personal Information
We take reasonable steps intended to ensure that your Personal Information we collect is accurate, complete, and current by using the most recent information provided to us.
Our websites and Services may allow you to review and edit your Personal Information by accessing your profile or similar feature of the website or Service you are using.
For our websites, you may have the ability to manage your cookies and similar technologies through your web browser settings. You should consult the settings and instructions provided by the provider of your web browser for more information.
You may also submit a request to us to review, edit, or delete your Personal Information by emailing your request to privacy@holistics.io
Our business hours for telephone contact are 9:00 AM to 6:00 PM Singapore Time (GMT+8). Once we verify your identify, we will assist you with your request.
You can choose not to provide us with your Personal Information, but if you do not provide us with your Personal Information when we request it, we may not be able to provide you with the websites and Services you use, or tailor them to you.
### Opting-out of Holistics Communications
We may occasionally send you notification emails about updates to our product, legal documents, offer customer support or marketing emails. Except for cases where we are required to do so by law (e.g. notifying you of a data breach), you shall have the opportunity to unsubscribe from receiving these messages free of charge.
You may opt out of receiving Communications by modifying your website or Service profile, or by unsubscribing to the marketing mailings or newsletters you no longer desire. To unsubscribe, please follow the "Unsubscribe" instructions that are contained within the mailing, newsletter or other Communication that we send to you.
You may also send an email to privacy@holistics.io with "Unsubscribe" in the body, together with a description of the Communications you no longer desire to receive.
### Minimum Age
The Site is not directed to, nor intended to be used by, individuals under the age of 13. Holistics does not knowingly collect personal information from individuals under the age of 13. If you become aware that an individual under the age of 13 has provided us with personal information, please contact us immediately at privacy@holistics.io. If we become aware that an individual under the age of 13 has provided us with personal information, we will take steps to delete such information.
### For More Information
If you have any questions or concerns about this Privacy Policy, please contact us at privacy@holistics.io or 14 Robinson Road, Far East Finance Building, #08-01A, Singapore 048545.
---
## Terms of service
:::tip Shareable link and download
This page is also available at the short, stable URL https://holistics.io/terms. To download a copy, visit https://go.holistics.io/terms, click **File >** download **Download As**, then select your preferred format.
:::
_Last Updated: 13 June 2026_
## **1\. Definitions**
**"Affiliate"** means any entity, whether now in existence or subsequently created, which directly or indirectly controls, is controlled by, or is under common control with a party to this Agreement. Control for the purposes of this definition is evidenced by direct or indirect ownership or control of more than 50% of the voting interests of the subject entity. Examples of such relationships include, but are not limited to, subsidiaries (entities controlled by a party), parents (entities that control a party), and siblings (entities under common control with a party).
**“Customer”, “you”, or “your”** refers to the person or entity using the Subscription Service and identified in the applicable account record, billing statement, online subscription process, or Subscription Agreement as the customer and your Affiliates in the scope of your purchase.
**“Customer Data”** refers to data that Holistics stores, processes, and/or secures for each Individual Product subscribed by the Customer as defined in the Individual Product Terms.
**"Embedded Solution"** refers to the incorporation, integration, or inclusion of any Holistics service or functionality from any **Individual Product** within a customer’s or third party's application, interface, or system in such a manner that it appears part of or operates seamlessly as part of the customer’s or third party's product offering. Customers are granted the right to resell these Embedded Solutions, including analytics and dashboards, subject to compliance with the terms outlined in this TOS.
**"Holistics", “we”, "us", or “our”** refers to Holistics Software Pte Ltd, a corporation incorporated under Singapore law, designated as a private company limited by shares.
**“Individual Product”** refers to one of the below software developed by Holistics.
* **“Holistics BI”** refers to the business intelligence software from Holistics ([www.holistics.io](https://www.holistics.io)).
* **“Holistics dbdiagram”** refers to the database diagramming software from Holistics ([www.dbdiagram.io](https://www.dbdiagram.io)).
* **“Holistics dbdocs”** refers to the database documentation software from Holistics ([www.dbdocs.io](https://www.dbdocs.io))
* **“Individual Product Terms”** refers to the terms for any one of the specific mentioned software in Holistics described in this TOS.
**"Party"** represents either Holistics or the Customer as a business entity.
**"Subscription Service" or "Service"** includes the software, professional services, and technical support services provided by Holistics listed under the Individual Product. This includes any updates, enhancements, new features, documentation, and educational content provided or made available to the Customer.
**"Subscription Agreement"** or **“Order Form”** means any agreement confirming the purchase of the Holistics Service, whether executed through in-app self-service processes, or via electronic signature of a Holistics Order Form. It becomes legally binding upon digital or electronic signing by an authorized representative of the Customer or by the completion of the subscription process within the Holistics application, each with the same legal force as a handwritten signature.
**“User” or "Users"** means employees, representatives, consultants, contractors, or agents authorized by the Customer to use the Subscription Service and have unique user identifications and passwords.
## **2\. General Terms**
**2.1 Acceptance of Terms:** By accepting this Terms of Service (TOS) or by accessing or using the Service, the Customer acknowledges that this TOS constitutes a legally binding agreement, enforceable in its electronic form. The Customer, as a legally constituted entity within its jurisdiction of formation, agrees to comply with all terms contained herein. The party entering into this Agreement is Holistics Software Pte Ltd, a corporation incorporated under the laws of Singapore and designated as a private company limited by shares, herein referred to as "Holistics." .
**2.2 Interpretation of Titles and Headings**: Titles and headings of sections of this TOS are for convenience only and shall not affect the construction of any provision of this Agreement.
**2.3 Entire Agreement and Order of Precedence**. This TOS is the entire agreement between Holistics and Customer regarding Customer’s use of Services and supersedes all prior and contemporaneous agreements, proposals or representations, written or oral, concerning its subject matter. The parties agree that any term or condition stated in a Customer purchase order or in any other Customer order documentation (excluding Subscription Agreements) is void. In the event of any conflict or inconsistency among the following documents, the order of precedence shall be:
* **2.3.1 The applicable Subscription Agreement(s):** This is specific to the services purchased and contains terms tailored to the individual transaction. It overrides other documents for aspects specifically addressed therein.
* **2.3.2 Custom Terms of Service:** This document provides customized terms for certain customers based on specific agreements and will take precedence over the Standard TOS where applicable.
* **2.3.3 This Standard Terms of Service (TOS):** Governs the general use of Holistics Services and applies to all customers unless superseded by more specific agreements as noted above.
* **2.3.4 The online documentation of the applicable Holistics Individual Product(s):** This includes user manuals, product guidelines, and operational procedures that provide detailed information about the use and limitations of the services but do not override the legally binding terms found in the aforementioned documents.
**2.4 Relationship of the Parties**. This TOS does not create a partnership, franchise, joint venture, agency, fiduciary or employment relationship between the parties. Each party will be solely responsible for payment of all compensation owed to its employees, as well as all employment-related taxes.
**2.5 Anti-Corruption**. Neither party has received or been offered any illegal or improper bribe, kickback, payment, gift, or thing of value from an employee or agent of the other party in connection with this TOS. Reasonable gifts and entertainment provided in the ordinary course of business do not violate the above restriction
**2.6 Customer Compliance**: The Customer must comply with all applicable laws and regulations in their use of the Holistics Subscription Service and ensure all data provided or used is lawful and properly authorized. Additionally, Customers are prohibited from engaging in any unlawful activities including, but not limited to, unauthorized replication or modification of the service’s functionality, creation of derivative works, reverse engineering, and unauthorized access to source code. However, Customers are permitted to resell Embedded Solutions, including analytics and dashboards, provided they adhere to the compliance requirements specified in this TOS. Violations of these provisions may lead to termination of service and legal action to protect Holistics' proprietary interests.
**2.7 Rights Reserved by Holistics:** Holistics' failure to enforce any provision of this TOS does not waive its right to do so in the future.
**2.8 End-of-Support**: Holistics reserves the right to discontinue support for features that have reached their end-of-support as communicated through official documentation and email announcements. Customers are advised to review these communications regularly to stay informed about which features are currently supported and any changes in support availability.
**2.9 Rights and Assignments**: The TOS cannot be assigned by the Customer without Holistics' consent, but Holistics may transfer this TOS with notice.
**2.10 Severability**. If any provision of this TOS is held by a court of competent jurisdiction to be contrary to law, the provision will be deemed null and void, and the remaining provisions of this Agreement will remain in effect.
**2.11 Amendments to TOS:** Holistics may revise these Terms from time to time to reflect changes in its services, laws, or regulatory requirements. If the Customer has an active Holistics subscription, Holistics will notify the Customer of any updates to the terms either via in-app notification or by email, provided that the Customer has opted to receive email updates.
## **3\. Intellectual Property and Rights**
**3.1 Intellectual Property Ownership**: Holistics retains all rights, titles, and interests, including intellectual property rights, in the Subscription Services. All rights not expressly granted to the Customer are reserved.
**3.2 Grant of Use**: Holistics grants the Customer a limited, worldwide, non-exclusive, non-transferable right to use the Subscription Service for internal business purposes, subject to compliance with this TOS. Additionally, Customers are granted the right to resell Embedded Solutions, including analytics and dashboards, to third parties. The Customer is entitled to access all functionality of the Subscription Service available as of the effective date stated in the Subscription Agreement.
**3.3 Metadata Rights:** Holistics may monitor Customer’s use of the Services and compile statistical and performance information in an aggregate and anonymous manner (“Metadata”), including to enhance service provision and operation. Holistics retains all intellectual property rights in such Metadata. Holistics shall ensure the use of Metadata respects the privacy and confidentiality agreements with the Customer and shall exclude personally identifiable information unless explicitly authorized. Customer agrees that Holistics may make the Metadata publicly available provided it does not incorporate any personally identifiable information or confidential Customer Data, nor does it identify Customer or its Confidential Information.
**3.4 Customer Feedback**: Holistics encourages feedback from all customers through our support communication channels. Customers agree that any feedback, suggestions, ideas, or other inputs provided to Holistics ("Feedback") will be considered non-confidential and non-proprietary. Holistics shall have a perpetual, irrevocable, worldwide, royalty-free license, including the right to sublicense, use, copy, modify, create derivative works from, and otherwise exploit any such Feedback for any purpose, without obligation or compensation to the provider. This Feedback may include, but is not limited to, improvements to Holistics' products, services, or processes.
**3.5 Publicity:** The Customer hereby grants Holistics the non-exclusive right to use the Customer's name and company logo in our customer list and on our website for marketing and promotional purposes. If the Customer wishes not to have their name and logo used in this manner, they may opt-out at any time by completing the Publicity Opt-Out Form available at \[[https://go.holistics.io/logo-opt-out](https://go.holistics.io/logo-opt-out)\]. For clarity, Holistics will not include its branding or logos within any Embedded Solution, which must be marketed solely under the Customer’s branding. However, Holistics may identify the Customer as a user of Holistics’ services in its marketing materials, including where the services are used in an Embedded Solution, unless the Customer has opted out as described above.
## **4\. Billing, Fees and Payment Terms**
**4.1 Payment Obligation**: The Customer is committed to fulfilling all payment obligations for fees associated with the Subscription Service, as detailed in the Subscription Agreement. These fees are payable in advance, underscoring the Customer’s responsibility to ensure timely payment as part of their agreement with Holistics.
**4.2 Non-Refundable Fees**: Fees for the Subscription Service, as outlined in the Subscription Agreement, are non-refundable and non-cancelable, except where specifically stated in this Agreement. This clause affirms the binding nature of the payment commitment by the Customer for the agreed Subscription Term.
**4.3 Obligation to Maintain Current Billing Information:** Customers are required to keep their billing information up to date and accurate, including their credit card information for the payment of fees. This responsibility extends to all information necessary for the processing of payments, such as legal company name, address (especially state and country), and the primary contact.
**4.4 Credit Card Information Security**: Holistics does not store credit card details on our servers, ensuring customer payment information is secure. Billing is conducted through reputable third-party billing agents employing industry-standard security measures.
**4.5 Secure Transaction Processing**: Customers authorize Holistics to charge their credit card for all subscription fees during the Subscription Term. This process is securely managed through trusted third-party processors, with whom customers agree to share their payment information.
**4.6 Standard Payment Terms:** Payments are billed in advance via credit card and are due immediately upon billing. In the event of a failed credit card payment, Holistics reserves the right to retry billing the customer's credit card. This includes instances where the credit card has expired or is no longer valid. Holistics may automatically resume billing upon the customer updating their credit card information.
**4.7 Custom Payment Terms:** For eligible customers on annual billing plans that exceed a minimum subscription amount, Holistics offers custom payment terms including the option to pay via bank transfer. A deposit may be required, and administrative fees apply if conditions are not met.
**4.8 Responsibility for Bank Transfer Fees:** Customers opting for bank transfer payments must ensure that the net amount received by Holistics equals the invoiced amount, accounting for any fees charged by their bank for the international transfer. Holistics will cover any fees charged by its bank for receiving the funds.
**4.9 Customer Tax Responsibility:** All Subscription fees quoted or charged by Holistics are exclusive of government-imposed sales taxes, levies, duties, or similar governmental assessments of any nature, including but not limited to value-added, sales, use, or withholding taxes. Customers are responsible for paying all such taxes associated with their purchases under this agreement. If Holistics has the legal obligation to pay or collect any of these taxes for which the Customer is responsible, Holistics will invoice these to the Customer, and the Customer will pay that amount unless they provide Holistics with a valid tax exemption certificate authorized by the appropriate taxing authority. For clarity, Holistics is solely responsible for taxes assessable against it based on its income, property, and employees.
**4.10 Renewal Fee Adjustments.** Upon renewal, Holistics reserves the right to adjust the Subscription Fees payable by the Customer up to the then-current list price as detailed on the public pricing page of our Individual Products. Holistics will provide the Customer with a notice of any such fee adjustments at least thirty (30) days prior to the end of the Customer's then-current Subscription Term. These adjusted fees shall be effective commencing from the first day of the subsequent renewal term.
**4.11 Overdue Charges:** Late payments are subject to a monthly penalty of 1.5% of the overdue amount, calculated using the prevailing exchange rate from the date of default.
**4.12 Suspension for Non-Payment:** If the Customer's payment is overdue, Holistics will issue up to three reminders to the billing contact(s) specified in the applicable Individual Product Terms. These reminders may consist of notifications for pending bank transfer payments or failed payment retry attempts for other automated payment methods (credit card payments) as detailed in Section 4.6. If the overdue payment is not resolved following these reminders, Holistics reserves the right to suspend access to the Subscription Services. The specific timeline for initiating suspension due to non-payment, and the process for lifting such suspensions, is detailed in the Individual Product Terms in Section 6 and Section 7 of this TOS.
**4.13 Payment Disputes**: Holistics will not exercise its rights under the “Overdue Charges” or “Suspension for Non-Payment” of this section if Customer is disputing the applicable charges reasonably and in good faith and is cooperating diligently to resolve the dispute.
## **5\. Term, Termination, and Suspension**
**5.1** **Duration and Renewal**. The initial term of the subscription shall commence as specified in the Subscription Agreement executed between the Customer and Holistics. Unless otherwise agreed in the said Subscription Agreement, the subscription shall automatically renew for the same term length or one year, whichever is shorter.
**5.2 Non-Renewal**. To prevent automatic renewal, the Customer must either cancel the subscription via the in-app billing page on their Holistics Individual Product’s application anytime before the renewal deadline or email a written notice of their intention not to renew at least three working days before the current subscription term ends. Detailed instructions for submitting a non-renewal notice or for early termination are available in the Individual Product Terms and the Holistics online documentation. It is the responsibility of the Customer to follow these procedures to ensure proper processing of their request
**5.3 Early Termination by Customer**. The Customer may terminate the subscription prior to the end of the term by providing thirty (30) days written notice. Upon such early termination, Holistics will not refund any prepaid fees or unused subscription fees. However, the Customer retains the right to continue using the Subscription Services until the end of the originally agreed term. The Customer remains obligated to settle any outstanding fees for the remaining subscription term.
**5.4 Termination for Cause**: This clause applies to any or all Subscription Services of the Individual Products under this TOS (including all related Subscription Agreements). Either party may terminate this Agreement for cause under the following conditions:
* **5.4.1 Material Breach:** Upon thirty (30) days' notice to the other party of a material breach if such breach remains uncured at the expiration of such period.
* **5.4.2 Insolvency and Bankruptcy:** Immediately, if the other party becomes the subject of a petition in bankruptcy or any proceeding relating to insolvency, cessation of business, liquidation, or assignment for the benefit of creditors, or if any such proceeding is instituted against such party (and not dismissed within sixty (60) days).
* **5.4.3 Cessation of Operations:** Immediately if the other party ceases its business operations without a successor.
* **5.4.4 Detrimental Conduct:** Upon thirty (30) days' written notice if one party reasonably determines that the other's conduct is damaging or could potentially damage the party’s reputation, business relationships, or operations. This includes, but is not limited to, engaging in illegal activities, fostering a hostile work environment, or other actions deemed significantly injurious to the other party's interests.
If the Customer terminates this Agreement for cause, Holistics will promptly refund any prepaid but unused fees covering the use of the Subscription Service after termination.
**5.5 Suspension for Cause:** Holistics may suspend the Customer's access to the Subscription Services, wholly or in part, under the following conditions:
* **5.5.1 Service Integrity:** If the Customer's use of the Subscription Services poses an immediate threat to the security, reliability, or integrity of the services, Holistics may suspend service access with immediate effect and will notify the Customer with the reason for suspension as soon as reasonably practicable.
* **5.5.2 Non-Payment:** For conditions leading to suspension due to non-payment, refer to the specific terms set out in Section 4.12. Suspension for non-payment will not apply if the Customer is disputing the overdue charges reasonably and in good faith.
Upon resolution of the conditions leading to the suspension, Holistics will promptly restore the Customer's access to the services.
**5.6 Termination Beyond Suspension:** The right to suspend service does not limit Holistics' right to terminate the Agreement for cause as outlined in this section, particularly if the Customer's actions have, or may negatively reflect on or affect Holistics, its prospects, or its customers.
**5.7 Support Conduct Policy:** In the event of customer misuse or abuse of support services, or disrespectful conduct towards Holistics staff, Holistics reserves the right to limit or suspend support services to the offending customer. This policy is essential to maintaining a respectful and effective support environment and ensures that our support resources are used appropriately.
**5.8 Non-Exclusivity of Termination Remedies:** Termination is not an exclusive remedy, and the exercise by either party of any remedy under this TOS will be without prejudice to any other remedies it may have under this TOS, by law, or otherwise.
## **6\. Individual Product Terms for Holistics Business Intelligence (BI)**
**6.1 Customer Data for Holistics BI:**
“**Customer Data for Holistics BI**” shall encompass all data and information provided by or on behalf of the Customer in connection with their use of the Holistics Business Intelligence (BI) software. Customer Data is classified into the following categories:
* **6.1.1 “Customer Database” or “Customer Databases”:** Refers to any and all data records stored within the databases connected to the Holistics BI software by the Customer, and the database user credentials necessary for such connection. It is important to note that Holistics does not store, warehouse, or retain any raw physical data records contained within the Customer’s databases. Instead, Holistics queries the data directly from the Customer's databases in real-time when a report is loaded, ensuring data privacy and minimizing data exposure.
* **6.1.2 “Query Results Cache” or “Cache”:** Refers to the output results of SQL queries executed against the Customer’s Database. These results are temporarily cached within the Holistics system to enhance software performance and reduce the load on the Customer’s Database. Cached data is subject to automatic expiration after a customer-defined duration, with a minimum cache duration of ten minutes. Detailed information on the caching mechanism employed by Holistics is available at Holistics Data Caching Documentation.
* **6.1.3 “Application Metadata”:** Includes data pertaining to the usage of the Holistics software by the Customer, as well as descriptive information inputted by the Customer to label, contextualize, and define the logic of data within the Holistics platform. This category encompasses, without limitation, report titles, column and formatting settings, data field formulas, logic, labels, analytics modeling definitions, and data delivery recipients. Application Metadata serves to facilitate the organization, interpretation, and application of analytics definitions to facilitate self-service analytics.
**6.2 Data Center Locations**: Holistics BI operates globally, with data centers in the US, Europe, and Asia. Customers can select the data center where their databases will be processed, in compliance with local data residency laws. More information on our data center locations is available at \[[https://docs.holistics.io/docs/data-centers](https://docs.holistics.io/docs/security-compliance/data-centers)\].
**6.3 Support Response Times:** Holistics BI responds to most support tickets within 2 business days for standard non-critical issues, often responding even quicker. More details can be found at \[[https://docs.holistics.io/docs/customer-support/support-sla](https://docs.holistics.io/docs/customer-support/support-sla)\]
**6.4 System Uptime:** Holistics BI targets for our system availability and uptime to be 99.5%. Real-time status of our system up-time can be found at \[[https://status.holistics.io](https://status.holistics.io)\]
**6.5 Support Impersonation:** Our support team may request permission to enable impersonation in-app for troubleshooting support tickets effectively. Users can disable this permission in-app at any time, and access in-app activity monitoring for transparency. Details on support impersonation are available at \[[https://docs.holistics.io/docs/customer-support/support-impersonation](https://docs.holistics.io/docs/customer-support/support-impersonation)\]
**6.6 Data Retention and Deletion**: Upon termination of the Subscription Service due to subscription cancellation or the expiration of a trial period, Holistics will retain the Customer's data for a period of 180 days, or upon the customer’s request, whichever is earlier. Following this period, the data will be automatically removed from the system. This retention period allows Customers the opportunity to reactivate their subscription or retrieve their data before permanent deletion. Details on Data Retention can be found at \[[https://docs.holistics.io/docs/data-retention-period](https://docs.holistics.io/docs/security-compliance/data-retention)\]
**6.7 Suspension for Non-Payment:** If payment for Holistics BI is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 30 days, access to the Service may be suspended.
**6.8 Legacy Products**: For Customers using legacy products from Holistics BI, it is essential to recognize that access to the full suite of features available in newer versions may be limited. Holistics enables Customers to verify their product version in-app within each Individual’s Product. Should a Customer determine they are using a legacy version and wish to upgrade, executing a new Subscription Agreement with Holistics is required to facilitate the transition to the most current product version.
**6.9 AI-Powered Features:** Holistics BI may make available optional features that use artificial intelligence and large language models ("AI Features") to assist Customers with tasks such as exploring data, generating analytics code, and producing summaries and descriptions.
* **6.9.1 Optional and Customer-Controlled:** AI Features are optional and operate only where enabled by the Customer. The Customer controls what data is shared with the AI Features through its in-app AI settings. The data accessed by each feature and the controls available to the Customer are described at \[[https://docs.holistics.io/docs/ai/data-access-and-policy](https://docs.holistics.io/docs/ai/data-access-and-policy)\].
* **6.9.2 Output Disclaimer:** AI Features generate output using probabilistic models and may produce information that is inaccurate, incomplete, or otherwise unreliable, and does not constitute professional advice. The Customer is responsible for reviewing and validating any AI output before relying on it or using it for any business, financial, legal, or other decision. AI Features form part of the Subscription Service and are provided "as is"; the disclaimers in Section 9.5 apply to them.
* **6.9.3 Ownership of Output:** As between the parties, the Customer's inputs to the AI Features remain Customer Data, and Holistics claims no ownership over the output generated for the Customer from those inputs. Holistics does not use Customer Data to train or improve its own or any third party's AI or machine-learning models, and contractually requires its AI sub-processors not to do so, as set out in the DPA and its Annexes.
* **6.9.4 AI Sub-Processors:** Where AI Features operate under Holistics' own provider keys, the AI (LLM) providers act as Holistics sub-processors and are listed in Annex 3 of the DPA. Where the Customer supplies its own provider key (bring-your-own-key), the Customer engages that provider directly under the Customer's own agreement, and that provider is not a Holistics sub-processor for the processing performed under the Customer's key.
* **6.9.5 Acceptable Use:** The Customer will not use the AI Features in violation of applicable law, any third party's rights, or the usage policies of the underlying AI providers, and will not use them to develop a competing product or model.
## **7\. Individual Product Terms for Holistics dbdiagram and dbdocs.**
**7.1 Customer Data Definitions:** “Customer Data” shall encompass all data and information provided by or on behalf of the Customer and is classified into the following categories:
* **7.1.1 “Customer Data for dbdiagram”:** Refers to the Entity Relationship Diagrams metadata stored on dbdiagram software by the Customer, including the information necessary for defining the database structure and visualizing the entity relationship diagram.
* **7.1.2 “Customer Data for dbdocs”:** Refers to the Database Documentation metadata stored on dbdocs software by the Customer, including the information necessary for defining the database structure and visualizing the database documentation.
* **7.1.3 Customer Data is only Metadata only:** For avoidance of doubt, both dbdiagram and dbdocs contain only metadata, and do not connect, store, and/or contain any live database records or credentials
**7.2 Data Center Locations**: dbdiagram and dbdocs are hosted with a reputable data center provider in Singapore, Asia.
**7.3 Support Response Times:** Response time during support hours is typically within 2 business days, and more details can be found at \[[https://dbdiagram.io/docs/support-sla](https://dbdiagram.io/docs/support-sla)\]
**7.4 System Uptime:** dbdocs and dbdiagram target our system availability and uptime to be 99.5%. The real-time status of our system up-time can be found at \[[https://status.dbdiagram.io](https://status.dbdiagram.io)\]
**7.5 Data Retention and Deletion**: Customer Data is retained permanently unless users submit a request to remove their account and all data related to them \[[https://dbdiagram.io/docs/faqs/remove-account](https://dbdiagram.io/docs/faqs/remove-account)\]
**7.6 Suspension for Non-Payment:** If payment for dbdiagram and/or dbdocs is overdue, Holistics will issue three reminders as outlined in Section 4.12. If the overdue payment is not resolved within 9 days, access to the Service may be suspended.
## **8\. Confidentiality**
**8.1 Confidential Information:** “Confidential Information” means all information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether orally or in writing, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and the circumstances of disclosure. For avoidance of doubt, this includes any proprietary information related to the Embedded Solutions that Customers are authorized to resell, provided such information is not publicly available or independently developed by the Receiving Party. For avoidance of doubt,
* **8.1.1 Confidential Information of Customer:** Refers to the **Customer Data** as mentioned in the Individual Product Terms.
* **8.1.2 Confidential Information of Holistics:** Refers to the Services of the Individual Product(s) subscribed by the Customer, the terms and conditions of this Agreement, custom pricing plans, and all exceptional commercial arrangements Holistics made for the Customer and are not available to the majority of other Customers.
* **8.1.3 Mutual Confidential Information:** Includes business and marketing plans, technology and technical information, product plans and designs, and business processes disclosed by such party.
**8.2 Confidential Information Exclusions**: Confidential Information does not include any information of the below categories:
* **8.2.1 Public Knowledge:** Information that is or becomes generally known to the public without breach of any obligation owed to the Disclosing Party,
* **8.2.2 Prior Knowledge:** Information was known to the Receiving Party prior to its disclosure by the Disclosing Party without breach of any obligation owed to the Disclosing Party,
* **8.2.3 Third-Party Information:** Information that is received from a third party without knowledge of any breach of any obligation owed to the Disclosing Party, or
* **8.2.4 Independent Development:** Information that was independently developed by the Receiving Party.
**8.3 Application of Confidentiality Obligations to Additional Services Evaluation:** For the avoidance of doubt, the non-disclosure obligations set forth in this “Confidentiality” section apply to Confidential Information exchanged between the parties in connection with the evaluation of each Individual Product.
**8.4 Protection of Confidential Information:** Each party agrees to take reasonable measures, at least substantially equivalent to the measures it takes to protect its own confidential information, to protect the confidentiality and avoid the unauthorized use, disclosure, publication, or dissemination of the other party’s Confidential Information.
**8.5 Data Minimization in Communications**: Customers are required to limit the sharing or exposure of sensitive or confidential data within communications, including support tickets. Information shared should be strictly necessary and relevant for the resolution of inquiries or issues. This practice of data minimization is crucial to protect sensitive information and minimize the risk of unauthorized data exposure.
**8.6**. **Customer Data Protection**: Holistics is dedicated to the security and confidentiality of Customer Data defined in our Individual Product Terms. We employ robust technical and organizational measures to protect against unauthorized access and loss. This commitment extends to maintaining commercially appropriate administrative, physical, and technical safeguards, as detailed in our Data Processing Agreement (DPA), including the Security Measures outlined in Annex 2 of the DPA. Further, we process Customer Data with a rigorously evaluated and approved list of sub-processors, specified in the DPA, chosen for their compliance with applicable data protection laws and standards, ensuring the highest level of security and confidentiality.
**8.7 Notification of Breach**: In the event of any breach of confidentiality or a data breach affecting Confidential Data, the Receiving Party shall promptly notify the Disclosing Party without unreasonable delay, considering the severity and volume of the breach in line with applicable regulations. The notification will include details of the breach, the steps taken to address it, efforts to regain possession of Confidential Information, and prevent its further unauthorized use, and any actions that affected parties should consider to protect themselves.
**8.8: Supersession of Prior NDAs:** The confidentiality obligations outlined in Section 8 of this TOS shall apply to all Confidential Information disclosed between the parties, including disclosures made prior to the effective date of this TOS. This TOS supersedes any previously executed non-disclosure agreements (NDAs) or confidentiality agreements between the parties, and all such agreements are hereby terminated in their entirety.
## **9\. Warranty and Indemnity**
**9.1 Service Warranty:** Holistics warrants that the Subscription Service will be performed in a manner consistent with generally accepted industry standards. This warranty does not apply to Free Services.
**9.2 Correction for Non-Conformance:** If the Subscription Service does not conform to the above warranty, Holistics shall correct any material reproducible impairments to the features and functionality of the Service so that it materially conforms to the applicable warranty within a commercially reasonable time following receipt of written notice of breach.
**9.3 Limitations**: Holistics is not liable under this warranty section if the non-conformance is caused by:
* **9.3.1 Third-Party Combinations:** Combining the Subscription Service with any hardware, software, equipment, or data not supplied by Holistics.
* **9.3.2 Unauthorized Modifications:** Any modification of the Subscription Service by any party other than Holistics, or modifications made by Holistics per specifications or instructions provided by the Customer.
* **9.3.3 Out-of-Scope Use:** Use of the Subscription Service in violation of or outside the scope of this Agreement.
* **9.3.4 Circumstances Beyond Reasonable Control:** Interruptions to the Subscription Service, including but not limited to outages, that are beyond its reasonable control, such as internet disruptions, cyber-attacks, or hardware failures. Efforts to mitigate such interruptions shall be undertaken promptly, but reparation for downtime or data loss will not exceed the pro-rata service fees paid by the Customer for the duration of the interruption.
**9.4 Exclusive Remedy**: If Holistics is unable to correct the non-conformity within sixty (60) days from when you notify us of the issue ("Remedy Period"), then either party may terminate this TOS by providing the other party written notice within thirty (30) days after the end of the Remedy Period. Upon such termination for non-conformance from the Customer, Holistics will refund any prepaid but unused fees covering use of the Subscription Service after termination.
**9.5 Disclaimer of Warranties**: Holistics provides the Subscription Service "as is" and does not make any representations or warranties regarding its suitability, reliability, availability, timeliness, security, accuracy, or completeness. This includes all implied warranties or conditions of merchantability, fitness for a particular purpose, title, and non-infringement. Application Programming Interfaces (APIs) and other features may not be available at all times and are subject to maintenance and updates.
Holistics does not warrant that the Subscription Service will be uninterrupted or error-free; use of the service may be affected by numerous factors outside our control. Except as expressly stated in Section 9.1 "Service Warranty," these exclusions apply to the fullest extent permitted by law.
**9.6 Indemnification:** Customer will defend, indemnify, and hold harmless Holistics, its affiliates, officers, directors, employees, agents, suppliers, licensors, and assigns from and against any claims, actions, proceedings, losses, damages, expenses, and costs (including but not limited to court costs and reasonable attorney fees) arising out of or in connection with:
- **9.6.1 Unauthorized Use:** Unauthorized or illegal use of Holistics' Services, noncompliance with this Agreement, or actions exceeding the scope of services as permitted under this Agreement.
- **9.6.2 Third-Party Integrations:** Integration or use of Holistics' services with non-Holistics applications, data sources, or configurations that were not provided or explicitly approved by Holistics, including claims arising from third-party components that are part of the ecosystem but not endorsed or provided by Holistics.
- **9.6.3 Unauthorized Access:** Unauthorized access to Holistics' services through the Customer’s information or infrastructure, regardless of whether the Customer had knowledge of such access.
- **9.6.4 Modifications Without Consent:** Modifications to Holistics' services by the Customer or by third parties engaged by the Customer without Holistics' prior written consent, particularly if such modifications lead to the claims asserted.
- **9.6.5 Misconfiguration and Administrative Errors:** Misconfiguration of user permissions or security settings by the Customer, which could have been configured or restricted through the normal use of Holistics' services.
## **10\. Free Services**
**10.1 Definition of Free Services**: "Free Services" includes the Holistics Subscription Service offered on an unpaid trial, freemium, or open source offering basis.
**10.2 Disclaimer of Warranties**: Holistics provides the Free Services on an "as is" and "as available" basis without any warranties of any kind, either express or implied. Holistics expressly disclaims all warranties, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.
**10.3 Restrictions on Use**: Access to Free Services is intended solely for evaluating the potential purchase of a Holistics Service subscription. Use of the Free Services for business purposes is permitted; however, any use for competitive analysis, resale, or any form of commercialization of Holistics' services is strictly prohibited.
**10.4 Typical Use Limitations**: Customers agree not to use the Free Services in any manner that substantially exceeds typical use projections. This includes, but is not limited to, excessive storage and bandwidth consumption, to ensure fair access and resource allocation for all users of the Free Services.
**10.5 Support for Free Services:** If you do not pay a Subscription Fee, your support is available to you through the community pages of the Individual Products.
**10.6 Limitation of Liability**: Holistics shall not be liable for any indirect, incidental, special, consequential, or exemplary damages, including but not limited to, damages for loss of profits, goodwill, use, data, or other intangible losses, even if Holistics has been advised of the possibility of such damages, arising out of or in connection with the Free Services. In no event shall the total liability of Holistics for all damages, losses, and causes of action related to the Free Services exceed US$500 per year, regardless of the number of claims.
**10.7 Termination Rights**: Holistics reserves the right to suspend, limit, or terminate Free Services at any time without notice, including for reasons of inactivity, security concerns, or maintenance.
**10.8 Modification of Free Services**: Holistics reserves the right to modify or discontinue, temporarily or permanently, any or all of the Free Services with or without notice to the Customer(s). Holistics is not liable to the Customer(s) or to any third party for any modification, suspension, or discontinuance of the Free Services.
## **11\. Account Information from Third-Party Providers**
**11.1 Third-Party Information Retrieval:** Customers may direct Holistics to retrieve certain information maintained online by third-party providers with whom the customer has a customer-vendor relationship.
**11.2 Authorization for Access:** Holistics may require the customer to provide the login information necessary to access the customer's account with third-party providers with whom the customer has a customer relationship. By using the Service and providing Customer Access Information, customers expressly authorize Holistics to access and use their account information maintained by identified third parties, on their behalf as their agent.
**11.3 Customer Representations and Warranties:** Customers represent and warrant that neither the foregoing (nor anything else in this TOS) nor their use of the Services will violate any agreement or terms to which they are subject, including without limitation, those with respect to any third-party site.
**11.4 Agency Relationship and Liability Disclaimer:** Customers acknowledge and agree that when Holistics accesses and retrieves account information from third-party sites, Holistics acts as the customer's agent and not as the agent of or on behalf of the third party. As such, Holistics is not liable for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such third-party services.
**11.5 No Liability for Third-Party Services:** Holistics does not guarantee that any such third-party services will continue to be made available within the Service, and such services may be removed or disabled by Holistics at any time without notice to the customer. Customers acknowledge and agree that the Service may not be sponsored or endorsed by the third-party services accessible through the Service.
## **12\. Limitation of Liability**
**12.1 Exclusion of Certain Losses:** Neither party shall be liable to the other for any indirect, incidental, special, consequential, or punitive damages, including loss of profits, data, or use incurred by the other party, except as allowed under mandatory applicable law.
**12.2 Liability Cap:** Each party’s cumulative liability under this TOS in relation to liability arising from any given event or series of connected events shall be limited to the total amount paid by Customer in the twelve (12) months immediately preceding the month in which the event (or first in a series of connected events) occurred.
**12.3. Exclusion for Service Interruptions**: Holistics specifically excludes liability for any compensation, reimbursement, or damages arising from the Customer’s inability to use the services due to:
* **12.3.1:** Termination or suspension of this Agreement or the Customer's use of or access to the Service offerings;
* **12.3.2:** Discontinuation of any or all of the Service offerings by Holistics;
* **12.3.3:** Any unanticipated or unscheduled downtime of all or a portion of the services for any reason, including as a result of power outages, system failures, or other interruptions attributable to third-party hosting or infrastructure providers used by Holistics.
**12.4 Exclusion for Customer Mismanagement**: Holistics specifically excludes liability for any losses or damages arising from the Customer’s mismanagement of their use of the services. This includes, but is not limited to:
* **12.4.1:** Failure to follow adequate data security practices that prevent unauthorized access to their accounts;
* **12.4.2:** Incorrect or improper configuration of the service settings by the Customer;
* **12.4.3:** The Customer's provision of incorrect or incomplete information that is necessary for the proper provisioning and operation of the services;
* **12.4.4:** Unauthorized actions taken by the Customer or their agents that compromise the integrity or confidentiality of data processed through the services.
**12.5 Agreement to Liability Limit**: The liability limits herein are fundamental to the pricing of Holistics' services. By agreeing to these terms, the Customer acknowledges that accepting increased liability would require an adjustment to the pricing structure.
## **13\. Governing Law and Dispute Resolution**
**13.1 Governing Law:** This Agreement shall be governed by and construed in accordance with the laws of the Republic of Singapore, without regard to its conflict of law principles.
**13.2 Jurisdiction:** The parties irrevocably agree that the courts of Singapore shall have exclusive jurisdiction to settle any dispute or claim that arises out of or in connection with this Agreement or its subject matter or formation (including non-contractual disputes or claims).
**13.3 International Arbitration:** Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity or termination, shall be referred to and finally resolved by arbitration administered by the Singapore International Arbitration Centre (“SIAC”) in accordance with the Arbitration Rules of the Singapore International Arbitration Centre (“SIAC Rules”) for the time being in force, which rules are deemed to be incorporated by reference in this clause. The seat of the arbitration shall be Singapore. The Tribunal shall consist of a sole arbitrator. The language of the arbitration shall be English.
**13.4 Virtual Arbitration Proceedings:** Unless mutually agreed otherwise, all arbitration sessions shall be conducted virtually. If the Customer and Holistics cannot agree on an arbitrator, the respective arbitration institution will appoint an arbitrator experienced in the B2B SaaS or Enterprise software industry.
**13.5 Arbitration Award Enforcement:** The arbitration process will yield a binding award, which may be recognized and enforced by any court of competent jurisdiction, thus affirming the finality of the decision.
**13.6 Individual Capacity Only:** Both Customer and Holistics shall conduct any arbitration on an individual basis only, expressly waiving any right to initiate or participate in a class action or to seek relief on a class basis.
**13.7 Limitation on Arbitrable Remedies:** In line with the limitations in Section 12 (Limitation of Liability), the arbitrator is not authorized to award any indirect, special, incidental, or consequential damages, including but not limited to lost profits, arising from or related to this TOS.
---
## Migrating to Holistics 3.0
:::info
To request an upgrade to **Holistics 3.0**, please fill in this [form](https://form.jotform.com/210551703814448).
:::
## Why am I here?
You're here because:
- If you signed up for Holistics before March 2020, your account is likely to be version 2.0 (or 2.5) of Holistics.
- You somehow heard about [Holistics 3.0](https://holistics.io/holistics-3) with all the self-service data reporting and data modeling capabilities. You're intrigued and wanted to try.
- This page explains to you about the difference between 2.0 and 3.0, and lay out details of migration for you.
## First, what is Holistics 3.0 and how is it better?
Holistics 3.0 is our next version of Holistics with a totally **different report-creation paradigm**.
Holistics 2.0 is designed as a "SQL to charts" BI tool. Where you write a SQL query (with some dynamic filters) and save it as a report.
While simple, the downside of this approach is:
- **Fixed Reporting:** Non-technical users cannot build their own reports without knowing SQL.
- **SQL definitions sprawl:** As reports increase, reports definitions get duplicated all over the place.
Holistics 3.0 takes a different approach by introducing a data modeling layer:
With Holistics 3.0, you will get:
- **Self-service:** Non-technical users can build their own reports without relying on data teams.
- **Central definitions:** All business logic are centralized and organized in one place.
:::info
**3.0/2.7 and 2.0/2.5 are not directly compatible**: Because both took very different approach ("SQL to charts" vs "modeling-based"), it's hard to provide an automated migration/upgrade from 2.0 to 3.0
:::
## Ok I'm interested. How can I upgrade to this new version of Holistics?
In order to upgrade to the new version of Holistics, please fill in this [form](https://form.jotform.com/210551703814448).
After we received your request, we will enable Holistics 2.7 for you within a day.
## What is Holistics 2.7?
Think of it as a Holistics version where both 2.0 and 3.0 functionalities are available to you.
* You can keep your existing 2.0 reports
* You can create new 3.0 reports based on existing 2.0 reports
This approach provides an incremental upgrade path where you can keep your existing business operations (2.0 reports), while experimenting with the new 3.0 report creation paradigm.
[Learn more](/faqs/holistics-2.7)
## Can you tell me more about the impact between Holistics 2.7 and Holistics 3.0?
Here's a [slide deck on the difference between Holistics 2.7 and Holistics 3.0.](https://docs.google.com/presentation/d/108kvhrTZHerOVj6uwbTWSSeORWjKcrPE1hmCB-ESuMM/edit#slide=id.gbbff7bc6d6_0_45) that we went through during the webinar on Feb 2021.
VIDEO
## Features not yet supported in 3.0
Not all features in 2.0 are available in 3.0 yet. These are features **not yet** present in Holistics 3.0:
- **Reporting:** No more Query Templates (replaced with Data Modeling)
- **Visualization** Inability to set [background color of widget](https://docs-v2.holistics.io/docs/widgets#widget-background)
- **Data Imports:** Google Drive import
- **Filters:** List filter (type), no more filter templates (replaced by filters 3.0)
- **Data Sources**: Oracle, Druid
- **Data Imports Limitations:** Post Import Query and Apply Conditions, New Column Source Expression
- **Data Transform Limitations:** New Adhoc Column; Post Transform Query, Suffix Options for Create Table
- **[Cache Settings] Auto Preload:** [Cache Auto Preload](https://docs-v2.holistics.io/docs/performance/data-caching#auto-preload) for dashboards and widgets.
## How do I check my current Holistics version?
You can click on the 'Help' button within Holistics and view the current version in the dropdown menu.
---
## Dimensions & Measures
## Introduction
Each data model in Holistics has two types of field:
- **Dimensions**: These are **non-aggregated fields** in a model which can refer directly to the underlying table's columns, or is created using non-aggregate functions to transform other dimensions.
- **Measures**: These are created by using **aggregate functions** (SUM, COUNT, etc) and do not need to pre-define grouping dimension. Instead, the grouping will be added dynamically to the generated SQL when you combine Dimensions and Measures.
:::info
In the Business Intelligence (BI) world, the words "metric" and "measure" are often used interchangeably. Some BI tools use the term "metric", while others use "measure". However, in general, these terms are intended to convey the same meaning. This makes sense, as the word "metric" is derived from the Greek word "metron", which translates to "a means of measure".
In Holistics, we have **both "metric" and "measure"**, where "measure" is defined in a model, while ["metric" is defined at the dataset level](/docs/metrics-in-datasets).
:::
## Dimensions
Dimensions are the building blocks of your data model. Here's how to add and configure them.
### Adding new dimensions
When creating a new Table Model or Query Model using Holistics's GUI, the model will be initialized with some dimensions:
- In the case of Table Model, the dimensions represent the underlying columns of the table in the database
- In the case of Query Model, the dimensions represent the columns in the result of your SQL
When you add a new column to the underlying table or query, you can click **Refresh Model** to automatically create new dimensions for them.
Dimensions can also be added manually:
1. Go to the model view UI, click **Add -> Custom Dimension**,
2. Input field name and definition (SQL or AQL formula).
3. Click **Create**, and a new column named will be created
Below is the general form of the dimension's syntax. You can define dimension in **either SQL or [AQL](/as-code/aql)**. For more details on how to define dimension with AQL, please refer to [single model dimension](/docs/dimensions-in-datasets#single-model-dimension).
```aml title="model_name.model.aml"
Model model_name {
...
// SQL-based dimension
dimension dimension_name {
label: 'Dimension Label'
type: 'text' | 'number' | 'date' | 'datetime' | 'truefalse'
description: 'Field Description'
hidden: true | false
definition: @sql {{ dimension }};;
}
// AQL based dimension
dimension aql_dimension_name {
label: 'Dimension Label'
type: 'text' | 'number' | 'date' | 'datetime' | 'truefalse'
description: 'Field Description'
hidden: true | false
definition: @aql aql_expression ;;
}
}
```
:::info
Please refer to [AML Dimension Reference](/reference/aml/field#dimension) to learn more about all available parameters and their example usage.
:::
### When to manually add dimensions
Some common scenarios you might want to manually defined dimensions:
- To discretize a field with continuous value:
```aml
definition: @aql case(
when: users.age > 30, then: 'Older than 30 years old',
when: users.age >= 18 and users.age < 30, then: '18 - 30 years old',
else: 'Under 10 years old'
) ;;
```
- To combine values of two fields to create a new value (e.g `field_a + field_b`)
```aml
definition: @aql orders.price - orders.discount ;;
```
- To convert the data type of a field, such as converting an integer to a string:
```aml
definition: @aql cast(users.ranking, 'text') ;;
```
## Measures
Measures let you define reusable aggregation logic. Here's how to create them.
### Adding new measures
:::info
Please refer to [AML Measure Reference](/reference/aml/field#measure) to learn more about all available parameters and their example usage.
:::
To add a Measure in your Data Model:
1. Go to the model view UI, click **Add -> Measure**,
2. Input field name and definition (SQL or AQL formula).
3. Click **Create**, and a new measure will be created
You can define a measure using either SQL or [AQL](/as-code/aql), but we recommend using AQL for most cases as it provides better syntax suggestions, typechecking, and validation. For more details, refer to [how to define metrics in AQL](/as-code/aql/learn/defining-a-metric).
```aml title="model_name.model.aml"
Model model_name {
...
// Simple aggregation
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
// AQL measures support more advanced use cases, such as percent of total
measure total_orders_of_all_countries {
label: 'Total Orders of all Countries'
type: 'number'
definition: @aql orders.total_orders | of_all(countries) ;;
}
measure country_orders_pct {
label: 'Country Orders Pct'
type: 'number'
definition: @aql orders.total_orders * 1.0 / orders.total_orders_of_all_countries ;;
}
}
```
### When to use measures
Measures are useful when you have aggregation logic that the basic aggregation in the Exploration UI cannot satisfy. For example:
- When your aggregation involves conditions:
```aml
// Count only delivered orders
measure delivered_orders {
label: 'Delivered Orders'
type: 'number'
definition: @aql count_if(orders, orders.status == 'delivered') ;;
}
```
- When your aggregation involves multiple fields:
```aml
// Calculate profit
measure profit {
label: 'Profit'
type: 'number'
definition: @aql sum(orders, orders.quantity * orders.price - orders.cost) ;;
}
```
## Important notes
- Manually defined dimensions and measures can only refer to fields **within the same model**. If you need to create a **cross-model field**, use Holistics's [Cross-model Calculation feature](/as-code/aql/learn/cross-model) with AQL metrics at the Dataset level. More details can be found in [creating metrics in datasets](/docs/metrics-in-datasets).
- The value of manually defined dimensions and measures are calculated **at run time**, and when you use [Persistence](/reference/aml/persistence) feature, they are not recorded to your database.
## Advanced Use Case
If you need to set up Dynamic Dimensions and Measures Selection in your dashboard, please refer to the following documentation:
- [Parameter Fields](/docs/modeling/param-fields)
- [Dynamic Dimensions Selection](/docs/modeling/dynamic-dimensions)
- [Dynamic Metrics Selection](/docs/modeling/dynamic-measures)
---
## Dataset Dimension
:::tip Useful resources
The following resources may help you grasp the concept of this document better:
- [AML Dimension & Measure](/reference/aml/field)
- [Cross-Model Calculation](/as-code/aql/learn/cross-model)
:::
## Introduction
Typically, a dimension is defined as a [field](/docs/model-fields) inside a data model, and it reflects a certain attribute of the model's data.
However, in complex reporting use cases, there is a need to create a dimensions that combines information across multiple models. For example in a [Cohort Retention](/as-code/aql/cookbook/aql-cohort-retention) report, we need a **Acquisition Cohort** dimension that reflects the month that a **user** placed their first **order**. In other words, the dimension combines information from the `users` and `orders` model.
Holistics's approach to solve this use case is to create a new **dimension at the dataset level** instead of model level.
## How to create dimensions in datasets
There are two ways to create dimensions in datasets:
- Create dimensions via UI in the Data tab.
- Create dimensions as-code in the Code tab.
Please note that **dataset dimensions are persisted to datasets directly**. They can be viewed and used by anyone who has access to these datasets. If you are just exploring different ways to answer a business question, you may want to conside **creating Ad-hoc Fields** instead.
## Create Dataset Dimension
:::info
Unlike [model fields](/docs/model-fields), you can only use [AQL](/as-code/aql/) to define dimensions and metrics in datasets, SQL definitions are not supported.
:::
A dimension can be declared inside a dataset using this syntax:
```aml
Dataset dataset_name {
(...)
dimension dimension_name {
model: source_model_name
type: 'text' | 'number' | 'date' | 'datetime' | 'truefalse'
label: 'Dimension Label'
definition: @aql aql_expression ;;
}
}
```
The `model` parameter defines the **source model** from which you will start referencing fields.
:::tip
For more details relating to **source model** and **field referencing**, please refer to the [Cross-Model Reference](/as-code/aql/learn/cross-model#cross-model-reference) document.
:::
In the following sections, we will go through different cases of dataset dimension.
## Single-model dimension
**Single-model dimensions** only reference fields of **one model** in their definitions. For example, the following dimensions only transform dimensions of the `users` model:
```aml
Dataset e_commerce {
(...)
dimension full_name {
model: users
type: 'text'
label: 'Full name'
definition: @aql concat(users.first_name, ' ', users.last_name);;
}
dimension age_by_year {
model: users
type: 'text'
label: 'Full name'
definition: @aql date_diff('day', users.birth_date, @now) / 365;;
}
}
```
In this case, we recommend defining these dimensions **at the model level**, so that information relating to a model is centralized in one place.
:::tip Best Practices
Single-model dimension should be defined **at the model level** instead of dataset level.
:::
## Cross-model dimension
**Cross-model dimensions** reference dimensions of **multiple models** that have [relationships](/docs/datasets/dataset-relationships) with one another.
For example, in the [Cohort Retention](/as-code/aql/cookbook/aql-cohort-retention) guide, we have the following dimension definition:
```aml
Dataset e_commerce {
(...)
relationships: [relationship(orders.user_id > users.id, true)]
dimension acquisition_month_cohort {
model: users
type: 'date'
label: 'Acquisition Month Cohort'
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
}
```
In case the models have a **1-1 relationship**, you can freely combine fields. However, in case the models have an **n-1 relationship**, the behavior is a bit more complicated.
:::tip Cross-Model Reference
For an in-depth explanation of the cross-model reference mechanism, please check our document about [Cross-Model Reference](/as-code/aql/learn/cross-model).
:::
### Source model is on the one-side
If the dimension's source model is on the **one-side** of the relationship, and you try to do a simple combination of the fields, the calculation will be blocked by the **"Cannot combine selected fields due to potential fan-out issues."** error.
For example, with models `orders` and `users` having a **n-1 relationship**, the following dimension will be invalid:
```aml
Dataset e_commerce {
(...)
relationships: [relationship(orders.user_id > users.id, true)]
// Invalid dimension
dimension simple_combination {
model: users // source model on the one-side
type: 'number'
label: 'Simple Combination'
definition: @aql users.id + orders.id ;;
}
}
```
The dimension only works when you aggregate the many-side with an [aggregator function](/reference/aql/aggregator-functions), like using the `min()` function in the `acquisition_month_cohort` example above.
:::tip
For more in-depth explanation of this behavior, please refer to the following documents:
- [Fan-out issues](/docs/joins/troubleshooting-fanout)
- [Cross-Model Reference](/as-code/aql/learn/cross-model#cross-model-reference)
:::
### Source model is on the many-side
If the dimension's source model is on the many-side of the relationship, you can freely reference fields of the one-side model. For example, this would work:
```aml
Dataset e_commerce {
(...)
relationships: [relationship(orders.user_id > users.id, true)]
// Valid dimension
dimension simple_combination {
model: orders // source model on the many-side
type: 'number'
label: 'Simple Combination'
definition: @aql users.id + orders.id ;;
}
}
```
---
## Dynamic Conditions in Visualization Block
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [AQL Condition](/reference/aql/aql-condition)
:::
## Introduction
With [AQL Conditions](/reference/aql/aql-condition), you can filter data in the visualization's filter section using expressions like:
```aml
buyers.buyer_gender == 'female' and sellers.seller_gender == 'female'
```
Dynamic Conditions take this a step further by **parameterizing those values** so users can control them via dashboard filters:
```aml
buyers.buyer_gender == {user_input} and sellers.seller_gender == {user_input}
```
This allows you to build interactive dashboards where filters apply to all metrics in the explore, without hardcoding values.
## Use Case: Filter Multiple Models with the Same Condition
A common scenario is filtering data from multiple models using a single user-controlled parameter. For example, filtering both buyers and sellers by the same gender value.
VIDEO
**Pre-requisite:** This example assumes you have an `ecom_transactions` dataset with the following structure:
```aml title="ecom_transactions.dataset.aml"
Dataset ecom_transactions {
...
models: [
buyers,
sellers,
transactions_buyers_sellers
]
relationships: [
relationship(transactions_buyers_sellers.buyer_id > buyers.buyer_id, true),
relationship(transactions_buyers_sellers.seller_id > sellers.seller_id, true)
]
}
```
Both `buyers` and `sellers` models have a `gender` dimension (e.g., `buyer_gender` and `seller_gender`).
### Step 1: Create a Parameter Model
```aml title="param_model.model.aml"
Model param_model {
type: 'query'
label: 'Param Model'
data_source_name: 'your_datasource'
query: @sql select 1 ;;
//highlight-start
param gender_param {
label: 'Gender'
type: 'text'
}
//highlight-end
}
```
### Step 2: Add the Parameter Model to Your Dataset
```aml title="ecom_transactions.dataset.aml"
Dataset ecom_transactions {
...
//highlight-next-line
models: [buyers, sellers, transactions_buyers_sellers, param_model]
relationships: [
relationship(transactions_buyers_sellers.buyer_id > buyers.buyer_id, true),
relationship(transactions_buyers_sellers.seller_id > sellers.seller_id, true)
]
}
```
### Step 3: Use Parameter in AQL Condition
In your visualization, add an [AQL Condition](/reference/aql/aql-condition) that references the parameter. This filter applies to the entire explore, affecting all dimensions and metrics.
```aml
// AQL Condition in the visualization's filter section
buyers.buyer_gender == (param_model.gender_param | first())
and
sellers.seller_gender == (param_model.gender_param | first())
```
This single condition filters both the buyers and sellers models using the same parameter value.
### Step 4: Set Up Dashboard Filter
1. Create a field filter linked to `param_model.gender_param`
2. Set its source to any gender field (from Buyers or Sellers model)
3. Configure the filter as single-select (since we use `first()`)
## Handling Single vs. Multiple Values
When using parameters in AQL Conditions:
**For single-value filters**, use `first()` to extract the first value:
```aml
buyers.buyer_gender == (param_model.gender_param | first())
```
**For multi-value filters**, use the `in` operator:
```aml
buyers.buyer_gender in param_model.gender_param
```
## See Also
- [Dynamic Metric Definition](/docs/modeling/dynamic-metric-definition) - For filtering inside metric definitions
- [Dynamic Query Model](/docs/query-parameters) - For filtering at the SQL level
- [AQL Condition Reference](/reference/aql/aql-condition)
- [Parameter Fields](/docs/modeling/param-fields)
---
## Dynamic Dimensions Selection
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [AQL Expression](/reference/aql/expression)
- [Dimension](/docs/model-fields#dimensions)
:::
## Introduction
Using [Parameter Fields](/docs/modeling/param-fields), we can create a chart with a dropdown input that allows users to change the chart's dimension dynamically.
## Dynamic Dimension Selection

Assuming that you have a model `users_model` with `country_name`, `city_name`, and `gender` dimensions. You want to build a dashboard that let user pick which dimension to break down by.
In the video, you want your metric to be dynamically broken down by `Countries`, `Cities`, and `Genders`
This can be implemented using [Parameter Field](/docs/modeling/param-fields) and [AQL Expression](/reference/aql/expression)
```typescript
// This is a model with a dynamic dimension set via a parameter
Model users_model {
dimension country_name { }
dimension city_name { }
dimension gender { }
}
```
### Step 1: Create a Parameter Field
First, create a [Parameter Field](/docs/modeling/param-fields) in the model.
```typescript
// This is a model with a dynamic dimension set via a parameter
Model users_model {
dimension country_name { }
dimension city_name { }
dimension gender { }
// Parameter to allow users to choose which dimension to use dynamically
//highlight-start
param dim_choice {
label: 'Dimension Choice'
type: 'text'
allowed_values: ['Countries', 'Cities', 'Gender']
}
//highlight-end
}
```
### Step 2: Create a Dynamic Dimension
Create a Dynamic Dimension that changes based on the selected parameter value.
```typescript
// This is a model with a dynamic dimension set via a parameter
Model users_model {
dimension country_name { }
dimension city_name { }
dimension gender { }
// Parameter to allow users to choose which dimension to use dynamically
param dim_choice { }
// Dynamic dimension that changes based on the selected parameter value
dimension breakdown_dim {
label: 'Dynamic Breakdown Dimension'
type: 'text'
//highlight-start
definition: @aql case(
when: 'Countries' in users_model.dim_choice
, then: users_model.country_name
, when: 'Cities' in users_model.dim_choice
, then: users_model.city_name
, when: 'Gender' in users_model.dim_choice
, then: users_model.gender
) ;;
//highlight-end
}
}
```
### Step 3: Use `Dynamic Breakdown Dimension` in the report
In the report, you can use `Dynamic Breakdown Dimension` as a dimension to break down your metric.
### Step 4: Use `Dimension Selector` Parameter Field in the dashboard filter
In the dashboard filter, you can use `Dimension Selector` Parameter Field to dynamically choose which dimension to use in the report.
## Advanced: Dimension selection across multiple models
:::warning Coming Soon
This feature is under development and will be coming soon!
:::
---
## Dynamic Metrics Selection
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [AQL Expression](/reference/aql/expression)
- [Measure](/docs/model-fields#measures)
:::
## Introduction
Using [param fields](/docs/modeling/param-fields), we can build charts with dropdown that allow users to change the metric dynamically.
## Dynamic Metrics Selection

Assuming that you have a model with 3 metrics: `total_orders`, `revenue`, and `total_users`. You want to build a dashboard that let user pick which measure to be used in your report.
```typescript
Model sales_model {
measure total_orders { }
measure revenue { }
measure total_users { }
}
```
This can be implemented using [Parameter Field](/docs/modeling/param-fields) and [AQL Expression](/reference/aql/expression)
### Step 1: Create a Parameter Field
First, create a [Parameter Field](/docs/modeling/param-fields) in the model.
```typescript
Model sales_model {
measure total_orders { }
measure revenue { }
measure total_users { }
//highlight-start
param metric_selector {
label: 'Metric Selector'
type: 'text'
allowed_values: ['Total Orders', 'Revenue', 'Total Users']
}
//highlight-end
}
```
### Step 2: Create a Dynamic Metric field
Create a Dynamic Metric that changes based on the selected parameter value.
```ts
Model sales_model {
measure total_orders { }
measure revenue { }
measure total_users { }
param metric_selector { }
//highlight-start
measure dynamic_metric {
definition: @aql case(
when: 'Total Orders' in sales_model.measure_selector
, then: sales_model.total_orders
, when: 'Revenue' in sales_model.measure_selector
, then: sales_model.revenue
, when: 'Total Users' in sales_model.measure_selector
, then: sales_model.total_users
) ;;
}
//highlight-end
}
```
### Step 3: Use `Dynamic Metrics` in the report
In the report, you can use `Dynamic Metrics` with another dimension.
### Step 4: Use Parameter Field in the dashboard filter
In the dashboard filter, you can use `Measure Selector` Parameter Field to dynamically choose which measure to use in the report.
## Advanced: Metrics selection at Dataset level
If your metrics are defined at the dataset level, we recommend **creating a dedicated model to store your parameter fields**. While you can technically place parameter fields in any existing model, having a dedicated model gives you a single, predictable place to manage them.
1. **Create a separate model to contain the `param`**
```tsx
Model param_model {
type: 'query' // can be either `table` or `query` model
query: @sql select 1 ;; // Dummy query
//highlight-start
param metric_selector {
label: 'Metric Selector'
type: 'text'
allowed_values: ['Revenue', 'Total Orders', 'Total Users']
}
//highlight-end
}
```
2. **Add the `param_model` to the Dataset and define the `dynamic_metrics` at the dataset level**
```tsx
Dataset ecommerce {
//highlight-next-line
models: [countries, cities, users, orders, param_model]
relationships: [ ]
metric revenue { }
metric total_orders { }
metric total_users { }
//highlight-start
metric dynamic_metrics {
label: 'Dynamic Metrics'
type: 'number'
definition: @aql
case(
when: 'Revenue' in param_model.metric_selector
, then: revenue
, when: 'Total Orders' in param_model.metric_selector
, then: total_orders
, when: 'Total Users' in param_model.metric_selector
, then: total_users
)
;;
}
//highlight-end
}
```
---
## Dynamic Currency Conversion
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [AQL Expression](/reference/aql/expression)
:::
## Introduction
Dynamic currency conversion allows users to view revenue and other monetary metrics in their preferred currency. Instead of creating separate metrics for each currency, you can use parameter fields to let users switch currencies on-the-fly from a dashboard filter.
VIDEO
## Example: Multi-Currency Revenue Display
Suppose you have an international e-commerce platform and want users to view revenue in their preferred currency (MYR, USD, or THB).
### Step 1: Create a Currency Parameter Model
```aml title="currency_param.model.aml"
Model currency_param {
type: 'query'
label: 'Currency Parameter'
data_source_name: 'your_datasource'
query: @sql select 1 ;;
//highlight-start
param currency {
label: 'Display Currency'
type: 'text'
allowed_values: ['USD', 'MYR', 'SGD', 'THB', 'VND', 'AUD']
}
//highlight-end
}
```
### Step 2: Define Dynamic Metric with Currency Conversion
Add the parameter model to your dataset and create a metric that switches calculation based on the selected currency.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
label: 'Ecommerce'
data_source_name: 'your_datasource'
models: [orders, currency_param]
// Base metric in original currency
metric gmv {
label: 'GMV (Original Currency)'
type: 'number'
definition: @aql sum(orders.value) ;;
}
//highlight-start
// Dynamic metric that converts to selected currency
metric gmv_converted {
label: 'GMV (Converted)'
type: 'number'
definition: @aql case(
when: 'USD' in currency_param.currency, then: gmv * 1,
when: 'MYR' in currency_param.currency, then: gmv * 4.47,
when: 'SGD' in currency_param.currency, then: gmv * 1.35,
when: 'THB' in currency_param.currency, then: gmv * 35.5,
when: 'VND' in currency_param.currency, then: gmv * 25450,
when: 'AUD' in currency_param.currency, then: gmv * 1.57
) ;;
// the above conversion rate might be adjusted in the future
}
//highlight-end
}
```
### Step 3: Create Dashboard Currency Selector
1. Add a dashboard filter linked to `currency_param.currency`
2. Configure the filter as single-select (since the case statement expects one value)
3. Set a default value (e.g., 'USD') to ensure the metric always has a valid calculation
Users can now switch between currencies, and the revenue metric will automatically display values in their selected currency.
## See Also
- [Dynamic Metric Conditions](./dynamic-metric-condition) - For filtering metrics
- [Parameter Fields](/docs/modeling/param-fields)
---
## Dynamic Metric Conditions
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [Where Function](/reference/aql/where)
:::
## Introduction
Dynamic metric conditions allow you to apply flexible filtering **inside individual metric definitions** using the `where()` function. This lets users control which data a specific metric includes, without affecting other metrics in the same explore.
:::info When to use this vs. Dynamic Explore Conditions
- **Dynamic Metric Condition** (this page): Filters applied inside metric definitions using `where()` - affects only that specific metric
- **[Dynamic Explore Conditions](/docs/modeling/dynamic-conditions)**: Filters applied in the visualization's filter/condition section - affects all metrics in the explore
:::
## Example: Filter Revenue by Merchant
Suppose you want to let users filter revenue by different merchants using a single metric, while keeping other metrics (like total orders) unaffected.
**Pre-requisite:** This example assumes you already have a `merchants` model with a `name` dimension and an `orders` model with a `total_revenue` measure.
```aml title="merchants.model.aml"
Model merchants {
...
dimension name {
label: "Merchant Name"
type: 'text'
}
}
```
```aml title="orders.model.aml"
Model orders {
...
measure total_revenue {
label: "Total Revenue"
type: 'number'
}
}
```
### Step 1: Create a Parameter Model
Create a Query Model to hold your parameter field.
```aml title="param_model.model.aml"
Model param_model {
type: 'query'
label: 'Param Model'
data_source_name: 'your_datasource'
query: @sql select 1 ;;
//highlight-start
param merchant_param {
label: 'Merchant'
type: 'text'
}
//highlight-end
}
```
### Step 2: Add Parameter Model to Your Dataset
Include the parameter model in your dataset and define metrics.
**When your AQL expects a single value**, use the `first()` function to extract only the first value:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
label: 'Ecommerce'
data_source_name: 'your_datasource'
models: [merchants, products, orders, order_items, param_model]
relationships: [
relationship(products.merchant_id > merchants.id, true),
relationship(order_items.product_id > products.id, true),
relationship(order_items.order_id > orders.id, true),
]
// Regular metrics without parameter - NOT affected by dashboard filter in step 3
metric total_revenue {
label: 'Total Revenue'
type: 'number'
definition: @aql sum(orders.item_values) ;;
}
metric total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
//highlight-start
// Metric with parameter - ONLY this metric is affected by dashboard filter in step 3
metric revenue_by_merchant {
label: 'Revenue by Merchant'
type: 'number'
definition: @aql
total_revenue
| where(merchants.name == (param_model.merchant_param | first()))
;;
}
//highlight-end
}
```
When you use all three metrics in an explore and apply the dashboard filter, only `revenue_by_merchant` will be filtered - `total_revenue` and `total_orders` will still show the overall totals.
**When your AQL can handle multiple values**, use the `in` operator instead:
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
label: 'Ecommerce'
data_source_name: 'your_datasource'
models: [merchants, products, orders, order_items, param_model]
relationships: [
relationship(products.merchant_id > merchants.id, true),
relationship(order_items.product_id > products.id, true),
relationship(order_items.order_id > orders.id, true),
]
// Regular metrics without parameter - NOT affected by dashboard filter in step 3
metric total_revenue {
label: 'Total Revenue'
type: 'number'
definition: @aql sum(orders.item_values) ;;
}
metric total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
//highlight-start
// Metric with parameter - ONLY this metric is affected by dashboard filter in step 3
metric revenue_merchants {
label: 'Revenue (Multiple Merchants)'
type: 'number'
definition: @aql
total_revenue
| where(merchants.name in [param_model.merchant_param])
;;
}
//highlight-end
}
```
### Step 3: Create Dashboard Filter
1. Add a dashboard filter linked to `param_model.merchant_param`
2. Users can now dynamically filter revenue by selecting one or more merchants
## See Also
- [Dynamic Explore Conditions](/docs/modeling/dynamic-conditions) - For filtering at the explore level
- [Dynamic Query Model](/docs/query-parameters) - For filtering at the SQL level
- [Where Function Reference](/reference/aql/where)
---
## Dynamic Top N
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [AQL Expression](/reference/aql/expression)
- [Top Function](/reference/aql/top)
:::
## Introduction
Using [Parameter Fields](/docs/modeling/param-fields), you can build charts that allow users to dynamically control how many top (or bottom) items to display. This is useful when users want flexibility in viewing top performers, outliers, or focusing on specific segments of their data.
VIDEO
## Use Case: Top Buyers by Order Count
Suppose you have an e-commerce dataset and want to let users dynamically choose how many top buyers to display based on their total orders.
**Pre-requisite:** This example assumes you already have a `users` model with a `full_name` dimension and an `orders` model with a `total_orders` measure.
```aml title="users.model.aml"
Model users {
...
dimension full_name {
label: "Full Name"
type: 'text'
}
}
```
```aml title="orders.model.aml"
Model orders {
...
measure total_orders {
label: "Total Orders"
type: 'number'
}
}
```
### Step 1: Create a Parameter Model
First, create a Query Model to hold your parameter field. This approach is useful when you want to reuse the parameter across multiple visualizations or when the parameter doesn't naturally belong to a specific data model.
```aml title="param_model.model.aml"
Model param_model {
type: 'query'
label: 'Param Model'
data_source_name: 'your_datasource'
query: @sql select 1 ;;
//highlight-start
param number_param {
label: 'Top N'
type: 'number'
}
//highlight-end
}
```
### Step 2: Add Parameter Model to Your Dataset
Include the parameter model in your dataset alongside your data models.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
label: 'Ecommerce'
data_source_name: 'your_datasource'
//highlight-next-line
models: [users, orders, param_model]
relationships: [
relationship(orders.user_id > users.id, true)
]
}
```
### Step 3: Use Dynamic Top N in AQL Condition
In your visualization, use the [AQL Condition](/reference/aql/aql-condition) to filter results to only show the top N users based on total orders.
```aml
explore {
dimensions {
full_name: users.full_name
}
measures {
total_orders: orders.total_orders
}
//highlight-start
filters {
users.full_name in (
top(
(param_model.number_param | first())
, users.full_name
, by: orders.total_orders
)
)
}
//highlight-end
}
```
The `top()` function here:
- Takes the parameter value `(param_model.number_param | first())` as N
- Returns the top N `users.full_name` values
- Ranks them by `orders.total_orders` in descending order
### Step 4: Create Dashboard Filter
1. Add a dashboard filter linked to `param_model.number_param`
2. Users can now dynamically change how many top buyers are displayed
## See Also
- [Dynamic Metrics Selection](/docs/modeling/dynamic-measures)
- [Dynamic Dimensions Selection](/docs/modeling/dynamic-dimensions)
- [Top Function Reference](/reference/aql/top)
- [Bottom Function Reference](/reference/aql/bottom)
---
## Dynamic Metric
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Parameter Fields](/docs/modeling/param-fields)
- [AQL Expression](/reference/aql/expression)
:::
## Introduction
Dynamic metric definitions allow you to create metrics that respond to user inputs from dashboard filters. By combining parameter fields with metric definitions, you can build flexible calculations that adapt based on user selections.
## Use Cases
| Use Case | Description | When to Use |
|----------|-------------|-------------|
| [Dynamic Metric Conditions](/docs/modeling/dynamic-metric-definition/dynamic-metric-condition) | Filter a specific metric based on user selection (e.g., revenue for selected merchants only) | Compare a filtered metric against overall totals in the same chart |
| [Dynamic Currency Conversion](/docs/modeling/dynamic-metric-definition/dynamic-currency-conversion) | Display monetary values in user-selected currencies (USD, MYR, SGD, etc.) | International teams need to view reports in their local currency |
| [Dynamic Top N](/docs/modeling/dynamic-metric-definition/dynamic-top-n) | Let users choose how many top/bottom items to show (e.g., Top 5 vs Top 10 customers) | Leaderboards, performance rankings, focusing on key performers |
## How It Works
The general pattern for dynamic metrics involves three steps:
1. **Create a Parameter Model** - A query model that holds your parameter fields
2. **Reference Parameters in Metrics** - Use the parameter in your metric's AQL definition
3. **Connect Dashboard Filters** - Link dashboard filters to the parameter fields
```aml
// Step 1: Parameter model
Model param_model {
type: 'query'
query: @sql select 1 ;;
param my_param {
label: 'User Input'
type: 'text' // or 'number', 'datetime', etc.
}
}
// Step 2: Reference in metric
Dataset my_dataset {
models: [orders, param_model]
metric dynamic_metric {
definition: @aql
some_metric | where(field == (param_model.my_param | first()))
;;
}
}
```
## Key Considerations
- **Single vs. Multiple Values**: Use `first()` when your calculation expects a single value; use `in` operator for multiple values
- **Default Values**: Always set default values in dashboard filters to ensure metrics calculate correctly
- **Performance**: Dynamic metrics add flexibility but may impact query performance for complex calculations
## See Also
- [Dynamic Explore Conditions](/docs/modeling/dynamic-conditions) - For filtering at the explore level (affects all metrics)
- [Dynamic Query Model](/docs/query-parameters) - For filtering at the SQL level
- [Dynamic Dimensions](/docs/modeling/dynamic-dimensions)
- [Dynamic Measures](/docs/modeling/dynamic-measures)
---
## Promote Ad-hoc Fields
## Introduction
In Holistics, not every metric needs to be predefined in the dataset. Users can create [ad-hoc fields](/docs/ad-hoc-fields) on the fly while exploring data, so they can answer questions quickly without waiting for the data team.
When an ad-hoc field proves useful, analysts can turn it into a shared, reusable definition. Instead of recreating the same formula across multiple dashboards, you can **promote** it into a permanent [Dataset Metric](/docs/metrics-in-datasets) or [Dataset Dimension](/docs/dimensions-in-datasets). This helps teams standardize trusted definitions while keeping exploration fast.

This unlocks two practical workflows:
- **Collaboration without sacrificing governance**: business users propose metrics by creating ad-hoc fields; analysts review and promote the best ones. Teams move faster while keeping shared definitions consistent and trustworthy.
- **Safe iteration for analysts**: test calculations in real dashboards, validate results immediately, and promote only when the definition is ready to standardize.
## Typical Workflow

Field promotion follows a natural progression from exploration to standardization:
1. **Explore**: Start by [exploring a dataset](/docs/data-exploration) or [building a visualization](/docs/dashboards/visualization-blocks) in a dashboard.
2. **Create**: Add [ad-hoc dimensions or metrics](/docs/ad-hoc-fields) to answer specific questions or test new calculations.
3. **Review**: Analysts review the ad-hoc field’s formula and validate **that** it makes sense for broader use (for example, the right filters, grain, and edge cases).
4. **Promote**: Promote the field into a permanent [Dataset Metric](/docs/metrics-in-datasets) or [Dataset Dimension](/docs/dimensions-in-datasets) so it can be reused consistently.
VIDEO
This workflow lets anyone experiment freely while ensuring only validated, valuable fields become part of the official dataset.
## How to Promote an Ad-hoc Field
Once you have an ad-hoc field you want to make permanent, you can promote it from the Development workspace.
1. Open the dashboard containing the ad-hoc field in **Development mode**.
2. Locate the ad-hoc field in the visualization settings or data panel.
3. Click the `...` menu next to the ad-hoc field.
4. Select **Promote** to convert it into a permanent dataset field.

After promotion, the field becomes part of the dataset and can be used across all reports and dashboards built on that dataset.
## See Also
- [Ad-hoc Fields](/docs/ad-hoc-fields) - How to create ad-hoc metrics and dimensions
- [Dataset Metrics](/docs/metrics-in-datasets) - Understanding dataset-level metrics
- [Dataset Dimensions](/docs/dimensions-in-datasets) - Understanding dataset-level dimensions
---
## AML & Semantic Layer
The **semantic layer** is the layer between your warehouse and everything that consumes data (dashboards, AI, embedded analytics, self-service). It's where models, dimensions, measures, datasets, and relationships are defined as composable code objects, and it's the substrate the rest of Holistics reasons from.
## How it fits together
Holistics's semantic layer is written in AML, a typed language purpose-built for analytics modeling. The diagram below shows where it sits: between your data sources and everything that consumes data.
The semantic layer is written in **[AML](/as-code/aml/)**, a typed language purpose-built for analytics modeling: first-class language constructs for models, datasets, and relationships, not YAML key-value structures.
The companion query language **[AQL](/as-code/aql/)** queries the semantic layer, and it gets its own section.
## What's in this section
The flow runs setup → language → building blocks → patterns → operations.
### Set up and learn the language
Plumb your warehouse, then get to know AML before you start modeling.
Plumb your warehouse so your models have something to read from.
The typed modeling language behind the semantic layer.
Why a real language beats schemaless key-value configs for modeling.
The thinking that shaped how AML is structured.
### Building blocks
The atoms of the semantic layer: models, relationships, and the datasets that make them self-service.
Turn warehouse tables into models with dimensions and measures.
Foreign keys become declarative relationships between models.
A dataset curates models into something business users can explore.
### Patterns and reuse
Higher-level structures and the language features that keep large models DRY.
Higher-level structures for organizing real-world semantic layers.
Route queries to pre-aggregated tables transparently for performance.
Constants, functions, modules, extends, and partials for large models.
### Operations
Operational knowledge for running a semantic layer in production.
How Holistics handles timezones and date logic across your models.
How joins work under the hood when models come together.
Production-grade conventions for a maintainable semantic layer.
## Where to start
Pick the path that matches where you're coming from.
Start with Connect a database, then move on to Build data models.
Read Why AML vs YAML first to see what changes.
The Looker migration guide maps LookML concepts onto Holistics.
---
## Create metrics in datasets
## How to create metrics in datasets
There are two ways to create metrics in datasets:
- Create metrics via UI in the Data tab.
- Create metrics as-code in the Code tab.
Please note that **dataset metrics are persisted to datasets directly**. They can be viewed and used by anyone who has access to these datasets. If you are just exploring different ways to answer a business question, you may want to conside **creating Ad-hoc Fields** instead.
## Common Metrics use cases
The next step after [defining a Dataset](/docs/datasets) is to add Metrics.
We will use the familiar `ecommerce` dataset to introduce you some of the most common metric use cases:
1. **Simple aggregation:** Number of orders (all time)
2. **Cross-model aggregation:** Total order value (all time)
3. **Multiple aggregations in one metric:** Average order value (Total order value / orders count) of customers (all time)
4. **Metric with filtering condition:** Average order value in the last 3 months
The final setup of a dataset with metrics will look something like this:
Final setup
**Dataset Definition**
```aml
Dataset ecommerce {
label: 'Ecommerce'
owner: "demo@holistics.io"
data_source_name: 'demodb'
models: [
orders,
order_items,
products,
users,
]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true)
]
// 1. Simple aggregation
metric count_orders {
label: 'Count Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
// 2. Cross-model aggregation:
metric sum_order_value {
label: 'Sum Order Values'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
// 3. Multiple aggregation
metric average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql sum_order_value / count_orders;;
}
// 4. Metric with condition:
metric aov_last3m {
label: 'AOV Last 3 Months'
type: 'number'
definition: @aql average_order_value | where(orders.created_at matches @(last 3 months)) ;;
}
}
```
**Model Relationships**
## Components of a Metric
:::info
Unlike [model fields](/docs/model-fields), you can only use [AQL](/as-code/aql/) to define dimensions and metrics in datasets, SQL definitions are not supported.
It is highly recommended to go through [AQL in 30 minutes](/as-code/aql/learn-in-30-minutes) to familiarize yourself with AQL before continue reading.
:::
A basic AQL Metric in Holistics **is required to have** the following components:
- The **[table expression](/reference/aql/table-expression)** that we will run the aggregation on e.g. `table`, `table | filter() | group() | select() ...`
- The **[field](/docs/model-fields)** to be aggregated e.g. `table.field`
- The **[aggregation function](/reference/aql/aggregator-functions)** e.g. `sum`, `count`, `min`, `max`, etc.
And optionally:
- The **[meric context](/as-code/aql/learn/metric-context)** that provides more advanced functionality for metrics e.g. filtering, level of details, window functions etc.
These four components are combined into a **[metric expression](/reference/aql/metric-expression)**. All of the following syntax are equivalent:
```aml
// Full form
aggregate_function(table, table.field)
// Shorthand form - where table is automatically used as the first argument
aggregate_function(table.field)
// With pipe operator
table | aggregate_function(table.field)
```
:::info Notes
- Please refer to [**Aggregate Functions**](/reference/aql/aggregator-functions) document for more details on different forms of aggregation functions.
- Please refer to [**Table Expression**](/reference/aql/table-expression) document for more details on how to construct table expressions. In this guide, we will work with a simple table expression consisting of **only a model name**.
- The `|` symbol is the [**pipe operator**](/reference/aql/operator#pipe) that take the result of the expression on the left and pass it as the first argument to the function on the right.
:::
## Create Metrics
### 1. Simple aggregation
The most basic metric in Holistics involves aggregating a single field in a single model. In this case, we simply `count` the values of `id` field in the `orders` model:
```aml
// 1. Simple aggregation
metric count_orders {
label: 'Count Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
```
### 2. Cross-model aggregation
Here we want to calculate the **value of orders**, which equals to **quantity of items in the order** x **the item's price**. The definition is now a bit more complicated:
```aml
metric sum_order_value {
label: 'Sum Order Values'
type: 'number'
definition: @aql order_items | sum(order_items.quantity * products.price);;
// This is also valid: sum(order_items, order_items.quantity * products.price)
}
```
In this metrics, there are two tables involved: `order_items` and `products`. To let Holistics know exactly from **which table should we run the aggregation on**, we have to explicitly specify it. In this case, it is the `order_items` table.
This AQL expression equals to the following SQL:
```sql
SELECT
SUM(order_items.quantity * products.price) AS sum_order_value
FROM order_items
LEFT JOIN products on order_items.product_id = products.id
```
:::info For cross-model aggregation to work correctly
There are two specific requirements to make cross-model aggregation works correctly:
1. **Aggregation table must be specified:** If it is not specified in the metric definition like so:
```aml
definition: @aql sum(order_items.quantity * products.price) ;;
```
you will encounter an **Invalid expression** error (that basically says the Holistics engine cannot know which table to aggregate on)
2. **Aggregation table must be the N-table in an N - 1 relationship:** For example, the relationship between `order_items` and `products` is N - 1. If you specify `products` as the aggregation table like so:
```aml
definition: @aql sum(products, order_items.quantity * products.price) ;;
```
you will encounter a [**fan-out error**](/docs/joins/troubleshooting-fanout).
:::
This is a very simple example of cross-model calculation. For more details about this feature, please check out the dedicated [Cross-model Calculation](/as-code/aql/learn/cross-model) doc.
### 3. Multiple aggregations in one Metric
To calculate **Average Order Value (AOV)**, we need to divide **total order value** by **number of orders**. In other words, AOV is an aggregation that involves **two other aggregations**.
Division is a simple arithmetic operation. We can combine the two aggregations like so:
```aml
metric average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price)
/ count(orders, orders.id);;
}
```
However, we can **reuse** the definition of those two metrics by simply referring their names:
```aml
metric average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql sum_order_value / count_orders;;
}
```
### 4. Metric with filtering condition
To calculate a metric only on a subset of records, like AOV of orders in the last 3 months, we append a condition to the metric:
```aml
metric aov_last3m {
label: 'AOV Last 3 Months'
type: 'number'
definition: @aql average_order_value | where(orders.created_at matches @(last 3 months)) ;;
}
```
:::tip
Useful documents that you should check out:
- [**where**](/reference/aql/where): For more details on condition functions
- [**AQL Operators**](/reference/aql/operator): List of logical operators that you can use in the conditions
- [**AQL Operators - Datetime section**](/reference/aql/operator#datetime): How to write datetime filtering conditions
- **[Metrics By Examples](/as-code/aql/cookbook/metrics-by-example)**: more examples of metrics expressions
:::
## Using Metrics
After everything is done, if you go to the **Data** tab to preview your dataset, you will see a new **Metrics** section on the left panel with all of the metrics you have created.
## More advanced Metrics use cases
Metrics in Holistics is a powerful tool that allows complex calculations. Here are some topics that may interest you once you have mastered the basics of Metrics:
- [Metrics by Example](/as-code/aql/cookbook/metrics-by-example)
- [Level of Details](/as-code/aql/cookbook/level-of-detail)
- [Cumulative Metrics](/as-code/aql/cookbook/aql-cumulative-metrics)
- [Moving Average](/as-code/aql/cookbook/aql-moving-average)
- [Nested Aggregation](/as-code/aql/cookbook/aql-nested-aggregation)
- [Cross-model Calculation](/as-code/aql/learn/cross-model)
---
## [Upcoming] Controlling which dimensions can be used with a metric
## Introduction
In a well-designed analytical setup, **each metric should only be broken down by dimensions that are semantically relevant to it**. While many fields may be technically joinable, only a subset of those combinations preserve the intended meaning of a metric.
This document shows how you can **explicitly control which dimensions can be used with each metric** in Holistics.
For example, in a typical multi-fact ([**galaxy schema**](/docs/modeling/modeling-patterns/galaxy-schema)) setup shown below, order-related metrics and inventory-related metrics **each have their own distinct set of compatible dimensions**.
## Problem: Invalid metric breakdowns
In complex modeling setup, it’s possible to run queries that are **technically valid but analytically misleading**.
In Holistics, these cases often show up as metric breakdowns that return results successfully, yet no longer represent a meaningful business concept. This typically happens when a **metric is segmented by dimensions that are outside the context of the fact it belongs to**.
For example, inventory-related metrics are not tied to individual orders or users. Breaking them down by order, user, or city attributes therefore does not make sense from a business perspective. However, in a multi-fact setup where fact tables share common dimensions, such combinations can still be queried and will return results.

Imagine a user asking: **"What is the total inventory quantity available by user email?"**. This question doesn't make sense, as inventory exists at the product level, not the user level. Yet with the default relationship setup, they can drag `Total Quantity Available` (from `fact_inventory`) and `User Email` (from `dim_users`) into a report, and the system will return results.
The most dangerous aspect of these queries is that they fail silently. Because the query executes successfully and produces plausible-looking output, end users may assume the results are correct, even though the breakdown itself is fundamentally invalid.
## Why this happens: Bidirectional relationship
In Holistics, a dimension from one model can be combined with a metric from another model as long as there's a path of relationships connecting them, regardless of how many models sit in between.
Take `dim_users` and `fact_inventory` as an example. A path exists between them:
```
dim_users → fact_orders → dim_products → fact_inventory
```

Because this path exists, the system allows you to break down `inventory` metrics by any dimensions from `dim_users`.
**This is technically valid, but analytically wrong.**
By default, **relationships in Holistics are bidirectional**, so filters and groupings can flow in either direction. This lets the system traverse from `fact_orders` through `dim_products` into `fact_inventory`, creating an unintentional cross-fact path.
The issue is that **inventory isn't tied to users**. One product can have multiple inventory records (across warehouses, for example), so grouping inventory by user inflates the results. The query runs successfully and the numbers look plausible, but they're misleading.
## The solution: Single-directional relationships
To prevent invalid cross-fact paths, you can control the direction in which filters and groupings flow between models using the [`filter_direction`](/docs/joins/filter-direction) property on relationships.

Setting `filter_direction` to `one_way` allows dimensions to filter and group metrics in fact models, but blocks the reverse. This means `dim_products` can filter `fact_inventory`, but `fact_orders` cannot traverse through `dim_products` to reach `fact_inventory`.
The guiding principle: **dimensions describe facts, not the other way around.**
## Applying this to a multi-fact example
Consider an e-commerce dataset with two fact tables:
* `fact_orders`
* `fact_inventory`
And shared dimensions like `users`, `products`, `merchants`, and `categories`.
To fix this, add `'one_way'` to all dimension-to-fact and dimension-chain relationships. By default, relationships have no `filter_direction` specified, which means they default to `two_way`. Adding `'one_way'` restricts the filter flow to one direction only.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [
dim_users, dim_products, dim_cities,
dim_merchants, dim_categories,
fact_orders, fact_inventory
]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true),
relationship(fact_orders.product_id > dim_products.id, true),
relationship(fact_inventory.product_id > dim_products.id, true),
relationship(dim_users.city_id > dim_cities.id, true),
relationship(dim_products.merchant_id > dim_merchants.id, true),
relationship(dim_products.category_id > dim_categories.id, true)
]
}
```
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [
dim_users, dim_products, dim_cities,
dim_merchants, dim_categories,
fact_orders, fact_inventory
]
relationships: [
//highlight-start
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'one_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
//highlight-end
]
}
```
With `'one_way'` set on all relationships:
* `fact_orders` metrics can only be grouped by dimensions that directly describe orders
* `fact_inventory` metrics can only be grouped by dimensions that directly describe inventory
* No accidental paths exist between fact tables
As a result:
* ✅ Valid breakdowns continue to work
* ❌ Invalid breakdowns are blocked by design
* ❌ Cross-fact joins are no longer possible
This turns semantic correctness into a **default guarantee**, not a guideline.
---
## Bringing denormalized and rollup tables into Holistics
## Question
:::info Question
"My dbt layer already handles cleaning, joining, and some aggregation, so I've got wide, pre-joined tables (denormalized) and a few rollup/summary tables (pre-aggregated) built for performance.
What's the best way to bring these into Holistics: model on top of them directly, or break everything back into normalized, fact and dimension tables?"
:::
## Answer
This is a common question when working with bringing data tables into BI/analytics tools: choosing between pre-joined/pre-aggregated tables and normalized tables.
Normally for most analytics tools, it's a real tradeoff:
| | Pre-joined / pre-aggregated tables | Normalized (fact + dimension) tables |
|---|---|---|
| Query performance | Fast, nothing to join or aggregate at query time | Slower, Holistics joins and aggregates at query time |
| Self-service flexibility | Rigid: only the dimensions baked in at build time are queryable, so it limits the range of questions end users can ask | Flexible: any dimension can be joined in, so end users aren't limited to what was pre-selected |
| Maintainability | Dimension values are duplicated in every such table (`flatten_orders`, `flatten_users`, etc.), so they can drift out of sync when something changes upstream | Dimensions live in one place, so there's a single source of truth to update |
In Holistics, **you don't have to choose.**
Holistics allows you to use _normalized data models_ for self-serve flexiblity, while utilizing the _pre-joined, pre-aggregated tables behind the scene_ for better performance. The engine automatically serves matching queries from those tables underneath, so end users get a clean, flexible model without giving up speed.
For normalized table modeling patterns, see [Star schema](/docs/modeling/modeling-patterns/star-schema) for a single fact table, or [Galaxy schema](/docs/modeling/modeling-patterns/galaxy-schema) once you have several sharing dimensions.
## Register your table as a pre-aggregate
Use [`ExternalPersistence`](/docs/aggregate-awareness/persistence#external-persistence) to point Holistics at your existing table. This works the same way whether your table is pre-joined (flattened, but at the same grain as the fact) or pre-aggregated (rolled up to a coarser grain, like daily or regional totals): either way, Holistics treats it as a precomputed table it can route matching queries to.
Say your normalized model is built on these source tables:
```dbml
Table tickets {
id integer [pk]
agent_id integer
customer_id integer
created_at datetime
}
Table agents {
id integer [pk]
name varchar
}
Table customers {
id integer [pk]
tier varchar
}
```
### Pre-joined (denormalized) table
Your existing pre-joined table might look like this, one row per ticket with the agent and customer attributes already flattened in:
```dbml
Table flatten_tickets {
ticket_id integer [pk]
agent_name varchar
customer_tier varchar
created_at datetime
}
```
Register it as a pre-aggregate on your `fct_tickets` model. Since `flatten_tickets` is at the same grain as `fct_tickets` (one row per ticket, no precomputed counts), map `ticket_id` as a dimension instead of a measure. Holistics then computes `total_tickets` as `COUNT(ticket_id)` at query time:
```aml
Dataset support {
models: [fct_tickets, dim_agents, dim_customers]
pre_aggregates: {
agg_tickets_flat: PreAggregate {
dimension ticket_id {
for: r(fct_tickets.id)
}
dimension agent_name {
for: r(dim_agents.name)
}
dimension customer_tier {
for: r(dim_customers.tier)
}
dimension created_at {
for: r(fct_tickets.created_at)
type: 'datetime'
}
persistence: ExternalPersistence {
table_name: 'flatten_tickets'
}
}
}
}
```
### Pre-aggregated (rollup) table
A pre-aggregated (rollup) table works the same way, just at a coarser grain. Your rollup table might look like this, one row per agent per day:
```dbml
Table daily_agent_summary {
ticket_date date
agent_name varchar
total_tickets integer
}
```
```aml
Dataset support {
models: [fct_tickets, dim_agents]
pre_aggregates: {
agg_tickets_daily_agent: PreAggregate {
dimension agent_name {
for: r(dim_agents.name)
}
dimension ticket_date {
for: r(fct_tickets.created_at)
type: 'date'
}
measure total_tickets {
for: r(fct_tickets.id)
aggregation_type: 'count'
}
persistence: ExternalPersistence {
table_name: 'daily_agent_summary'
}
}
}
}
```
Thanks to [dimension awareness](/docs/aggregate-awareness/dimension-awareness) and [join awareness](/docs/aggregate-awareness/join-awareness), Holistics automatically routes matching queries to your existing table instead of joining or aggregating at query time. (The pre-aggregate's dimension and measure names just need to match your table's column names.)
**The result:** end users and other datasets see a clean, reusable, normalized model. Underneath, eligible queries transparently hit your fast, pre-joined or pre-aggregated table. You get the flexibility of normalized modeling and the performance of your existing tables, without picking one over the other.
## Additional resources
- [Aggregate Awareness overview](/docs/aggregate-awareness)
- [Data models in Holistics](/docs/data-model)
- [Relationships overview](/docs/relationships)
- [Datasets overview](/docs/datasets/)
---
## Galaxy schema (fact constellation)
## What is galaxy schema?
Galaxy schema, also called fact constellation, is when you have multiple fact tables that share the same dimensions. Think of it as multiple star schemas connected through common dimensions.
**Visual structure:**
## When to use galaxy schema
Galaxy schema is the right choice when you need to:
- Analyze multiple business processes together (like orders and inventory)
- Work with different facts that share common dimensions (products, dates, locations)
- Build metrics that span multiple fact tables
- Get a comprehensive view of your business
If you only have a single business process or your facts don't share dimensions, [Star schema](/docs/modeling/modeling-patterns/star-schema) is simpler and more appropriate.
## Handling multi-path selection
When multiple facts connect to the same dimensions, there are multiple possible paths between models. Holistics automatically chooses the most analytically correct path using a [ranking algorithm](/docs/datasets/dataset-relationships#path-ambiguity) based on path tier, weight, and length.
**Example:** When you query "total quantity available by product", Holistics intelligently selects the direct path:
- **Selected path:** `dim_products` → `fct_inventory`
- **Pattern:** Dimension to fact (one-to-many)
- **Tier:** Tier 1 (Best - pure one-to-many relationships)
- **Length:** 2 hops
- **Not-selected path:** `dim_products` → `fct_orders` → `dim_dates` → `fct_inventory`
- **Pattern:** Mixed - goes through multiple facts
- **Tier:** Tier 4 (Mixed pattern not following standard analytics patterns)
- **Length:** 4 hops
Holistics automatically selects the first path because **Tier 1 ranks higher than Tier 4**. The direct dimension-to-fact relationship follows the most common and reliable analytics pattern.
For more details on how path selection works, see [Path ambiguity in dataset](/docs/joins/path-ambiguity).
## Step-by-step implementation
### Step 1: Create your fact models
Start by defining each fact model for your different business processes. Each fact represents a distinct measurable event - in this example, we have orders and inventory.
```aml title="fct_orders.model.aml"
Model fct_orders {
type: 'table'
table_name: 'ecommerce.orders'
data_source_name: 'your_data_source_name'
dimension id {
label: 'Order ID'
type: 'number'
primary_key: true
hidden: true
}
dimension product_id {
label: 'Product ID'
type: 'number'
hidden: true
}
dimension created_at {
label: 'Created At'
type: 'datetime'
}
dimension item_value {
label: 'Item Value'
type: 'number'
}
measure total_gmv {
label: 'Total GMV'
type: 'number'
definition: @aql sum(fct_orders.item_value);;
}
}
```
```aml title="fct_inventory.model.aml"
Model fct_inventory {
type: 'table'
table_name: 'ecommerce.inventory'
data_source_name: 'your_data_source_name'
dimension id {
label: 'Inventory ID'
type: 'number'
primary_key: true
hidden: true
}
dimension product_id {
label: 'Product ID'
type: 'number'
hidden: true
}
dimension created_at {
label: 'Created At'
type: 'datetime'
}
dimension quantity_available {
label: 'Quantity Available'
type: 'number'
}
measure total_quantity {
label: 'Total Available Quantity'
type: 'number'
definition: @aql sum(fct_inventory.quantity_available);;
}
}
```
### Step 2: Create shared dimension models
These are the dimensions that both fact tables will connect to. Since they're shared across multiple facts, they act as the common ground for cross-process analysis.
```aml title="dim_products.model.aml"
Model dim_products {
type: 'table'
table_name: 'ecommerce.products'
data_source_name: 'your_data_source_name'
dimension id {
label: 'Product ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Product Name'
type: 'text'
}
dimension category {
label: 'Category'
type: 'text'
}
}
```
```aml title="dim_dates.model.aml"
Model dim_dates {
type: 'table'
table_name: 'ecommerce.dates'
data_source_name: 'your_data_source_name'
dimension date {
label: 'Date'
type: 'date'
primary_key: true
}
dimension month {
label: 'Month'
type: 'text'
}
dimension quarter {
label: 'Quarter'
type: 'text'
}
}
```
### Step 3: Build dataset with all relationships
Now connect everything in a dataset. Notice that all relationships are active - Holistics will automatically choose the best path for each query based on what you're trying to analyze.
```aml title="galaxy_schema.dataset.aml"
Dataset galaxy_schema {
label: 'Orders and Inventory Analysis'
data_source_name: 'your_data_source_name'
models: [
fct_orders,
fct_inventory,
dim_products,
dim_dates
]
relationships: [
// Orders connections
relationship(fct_orders.product_id > dim_products.id, true),
relationship(fct_orders.created_at > dim_dates.date, true),
// Inventory connections (all active - Holistics handles ambiguity automatically)
relationship(fct_inventory.product_id > dim_products.id, true),
relationship(fct_inventory.created_at > dim_dates.date, true)
]
// Holistics automatically uses the direct path: fct_inventory → dim_products
metric total_available_products {
label: 'Total Available Quantity'
type: 'number'
definition: @aql
fct_inventory | sum(fct_inventory.quantity_available)
;;
}
// Uses the direct path: fct_orders → dim_products
metric total_gmv {
label: 'Total GMV'
type: 'number'
definition: @aql fct_orders | sum(fct_orders.item_value);;
}
}
```
## Using both facts together
```aql
explore {
dimensions {
dim_products.name
}
measures {
total_available_products,
total_gmv
}
filters {
dim_dates.date matches @(last 6 months)
}
}
```
**Result:**
## Next steps
- **[Snowflake dimensions](/docs/modeling/modeling-patterns/snowflake-dimensions)** - Add dimension hierarchies
- **[Role-playing dimensions](/docs/modeling/modeling-patterns/role-playing-dimensions)** - Same dimension, multiple meanings
- **[Handle path ambiguity guide](/docs/joins/path-ambiguity)** - Deep dive into ambiguity resolution
---
## Additional resources
- [Dataset relationships](/docs/datasets/dataset-relationships)
- [with_relationships() reference](/reference/aql/with_relationships)
---
## Modeling patterns in Holistics
## Introduction
Good data modeling is the foundation of a successful analytics platform. The way you structure your models determines whether your analytics will be fast, accurate, and easy to maintain - or slow, confusing, and brittle.
This guide walks you through proven modeling patterns used by data teams worldwide, showing you how to implement them effectively in Holistics with real examples and practical solutions.
## Understanding fact and dimension models
Before diving into specific patterns, you need to understand the two fundamental building blocks of data modeling:
### Dimension models
**Dimension models** provide context - the "who, what, where, and when" of your data.
**Characteristics:**
- Describe business entities (customers, products, locations, dates)
- Contain descriptive attributes for filtering and grouping
- Have a unique primary key
- Usually have fewer rows (lower cardinality)
- The "one" side of relationships
**Example:**
```aml title="dim_products.model.aml"
Model dim_products {
type: 'table'
table_name: 'ecommerce.products'
dimension id {
label: 'Product ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Product Name'
type: 'text'
}
dimension category {
label: 'Category'
type: 'text'
}
}
```
### Fact models
**Fact models** store measurable events - the "how many" and "how much" of your data.
**Characteristics:**
- Store business events or transactions (orders, page views, inventory levels)
- Contain metrics you want to analyze (revenue, quantity, count)
- Have foreign keys linking to dimension models
- Usually have many rows (high cardinality)
- The "many" side of relationships
**Example:**
```aml title="fct_orders.model.aml"
Model fct_orders {
type: 'table'
table_name: 'ecommerce.orders'
dimension id {
label: 'Order ID'
type: 'number'
primary_key: true
hidden: true
}
dimension user_id {
label: 'User ID'
type: 'number'
hidden: true
}
dimension created_at {
label: 'Order Date'
type: 'datetime'
}
dimension amount {
label: 'Amount'
type: 'number'
}
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(fct_orders.id) ;;
}
measure total_revenue {
label: 'Total Revenue'
type: 'number'
definition: @aql sum(fct_orders.amount) ;;
}
}
```
**Quick classification:**
- "What am I measuring?" → **Fact models**
- "What am I grouping by?" → **Dimension models**
## Common modeling patterns
### [Denormalized and rollup tables](/docs/modeling/modeling-patterns/denormalized-rollup-tables) (built for performance upstream)
A single table with everything already joined and flattened, or rolled up to a coarser grain, often produced upstream in dbt or another ETL tool.
**When to use:**
- Fixed, known dashboards where the questions won't change
- You want the fastest path to a first working model
For self-service datasets, Holistics recommends modeling fact and dimension tables instead (see below). You can still reuse your existing table for performance via [Aggregate Awareness](/docs/aggregate-awareness).
[Learn more about bringing denormalized and rollup tables into Holistics →](/docs/modeling/modeling-patterns/denormalized-rollup-tables)
### [Star schema](/docs/modeling/modeling-patterns/star-schema) (single fact, multiple dimensions)
The most common pattern where one fact table connects to multiple dimensions, creating a star-like structure.
**When to use:**
- One primary business process to analyze
- Simple, fast queries
**Visual:**
[Learn more about Star Schema →](/docs/modeling/modeling-patterns/star-schema)
### [Galaxy schema](/docs/modeling/modeling-patterns/galaxy-schema) (multiple facts, shared dimensions)
Multiple fact tables sharing common dimensions, also known as fact constellation schema.
**When to use:**
- Analyzing multiple business processes together
- Different facts share common dimensions
- Building metrics that span multiple fact tables
**Visual:**
[Learn more about Galaxy Schema →](/docs/modeling/modeling-patterns/galaxy-schema)
### [Snowflake schema](/docs/modeling/modeling-patterns/snowflake-dimensions) (normalized dimensions)
A schema pattern where dimensions are normalized into multiple related tables, creating hierarchical structures.
**When to use:**
- Source data is already normalized
- Storage efficiency is critical
- Multiple dimension hierarchies exist
- Data normalization is important
[Learn more about Snowflake Schema →](/docs/modeling/modeling-patterns/snowflake-dimensions)
### [Role-playing dimensions](/docs/modeling/modeling-patterns/role-playing-dimensions)
The same dimension used multiple times in a fact table, each with different meanings.
**When to use:**
- Multiple date fields in a fact (created, shipped, delivered)
- Same dimension referenced in different contexts
**Example:** Orders with created_at, delivered_at, cancelled_at, refunded_at all linking to the same date dimension.
[Learn more about Role-Playing Dimensions →](/docs/modeling/modeling-patterns/role-playing-dimensions)
## Quick reference
### Pattern selection guide
| Pattern | Use when |
|---------|----------|
| **Denormalized / rollup tables** | Fixed/known dashboards, fastest path to a first model |
| **Star schema** | Single business process, simple queries, performance priority |
| **Galaxy schema** | Multiple facts, shared dimensions |
| **Snowflake schema** | Normalized source data, storage efficiency, dimension hierarchies |
| **Role-playing dimensions** | Same dimension, multiple meanings |
## Additional resources
**Core concepts:**
- [Data models in Holistics](/docs/data-model)
- [Relationships overview](/docs/relationships)
- [Datasets overview](/docs/datasets/)
**Advanced topics:**
- [Handle path ambiguity](/docs/joins/path-ambiguity)
- [Dataset relationships](/docs/datasets/dataset-relationships)
- [with_relationships() reference](/reference/aql/with_relationships)
**Troubleshooting:**
- [Fan-out issues](/docs/joins/troubleshooting-fanout)
---
## Role-playing dimensions pattern
## What are role-playing dimensions?
Role-playing dimensions are when the same dimension is used multiple times in a fact table, each time with a different meaning. The classic example is dates: order date, ship date, delivery date all reference the same date dimension.
For example, an Order model contains information about when an order is created, delivered, cancelled, and refunded. You want to enable users to analyze orders by any of these dates - but they all refer to the same underlying date dimension.
**Visual:**
## The challenge
In Holistics, **only ONE relationship between two models can be active at a time**.
This means you cannot have multiple active relationships from `fct_orders` to `dim_dates` simultaneously. If you try to analyze orders by date, which date should be used? Created date? Delivered date?
## Solution
Holistics provides two approaches to handle role-playing dimensions:
1. **Using [`with_relationships()`](/reference/aql/with_relationships)** - Define multiple relationships (only one active) and explicitly specify which relationship each metric should use
2. **Using [`extend()`](/reference/aml/extend)** - Create separate extended models for each role, avoiding the one-active-relationship limitation
Let's explore both approaches.
## Approach 1: Using [`with_relationships()`](/reference/aql/with_relationships)
This approach uses a single date dimension with multiple relationships, where you explicitly specify which relationship to use for each metric.
### Step 1: Define models
First, create your fact model with multiple date fields that will reference the same date dimension. Notice how each date field represents a different stage in the order lifecycle.
```aml title="fct_orders.model.aml"
Model fct_orders {
type: 'table'
table_name: 'ecommerce.orders'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Order ID'
type: 'number'
primary_key: true
hidden: true
}
// Multiple date fields (role-playing)
dimension created_at {
label: 'Created At'
type: 'datetime'
}
dimension delivered_at {
label: 'Delivered At'
type: 'datetime'
}
dimension cancelled_at {
label: 'Cancelled At'
type: 'datetime'
}
dimension refunded_at {
label: 'Refunded At'
type: 'datetime'
}
measure order_count {
label: 'Order Count'
type: 'number'
definition: @aql count(fct_orders.id);;
}
}
```
```aml title="dim_dates.model.aml"
Model dim_dates {
type: 'table'
table_name: 'ecommerce.dates'
data_source_name: 'your_datasource_name'
dimension date {
label: 'Date'
type: 'date'
primary_key: true
}
dimension month {
label: 'Month'
type: 'text'
}
dimension quarter {
label: 'Quarter'
type: 'text'
}
}
```
### Step 2: Define dataset with multiple relationships
Set up your dataset with multiple relationships to the same dimension. Only one can be active by default, and you'll use `with_relationships()` to specify which one to use for each metric.
```aml title="role_playing_example.dataset.aml"
// Dataset with role-playing dimension
Dataset role_playing_example {
data_source_name: 'your_datasource_name'
models: [fct_orders, dim_dates]
relationships: [
// Only ONE active by default
relationship(fct_orders.created_at > dim_dates.date, true), // Active
relationship(fct_orders.delivered_at > dim_dates.date, false), // Inactive
relationship(fct_orders.cancelled_at > dim_dates.date, false), // Inactive
relationship(fct_orders.refunded_at > dim_dates.date, false) // Inactive
]
// Default metric uses active relationship (created_at)
metric total_created_orders {
label: 'Total Created Orders'
type: 'number'
definition: @aql fct_orders | count(fct_orders.id);;
}
// Override with with_relationships() for other dates
metric total_delivered_orders {
label: 'Total Delivered Orders'
type: 'number'
definition: @aql
fct_orders
| count(fct_orders.id)
| with_relationships(fct_orders.delivered_at > dim_dates.date)
;;
}
metric total_cancelled_orders {
label: 'Total Cancelled Orders'
type: 'number'
definition: @aql
fct_orders
| count(fct_orders.id)
| with_relationships(fct_orders.cancelled_at > dim_dates.date)
;;
}
}
```
### Step 3: Query with role-playing dimensions
```aml
explore {
dimensions {
dim_dates.date
}
measures {
total_created_orders,
total_delivered_orders,
total_cancelled_orders
}
}
```
### Key points for `with_relationships()` approach
This approach gives you:
- Explicit control over which relationship each metric uses
- Works well for metrics defined in datasets
- Trade-off: Requires `with_relationships()` for every metric using inactive paths
## Approach 2: Using `extend()`
[AML extend](/reference/aml/extend) provides a cleaner way to implement role-playing dimensions without the one-active-relationship limitation. Instead of managing multiple relationships to one model, you create separate extended models for each role.
### How it works
With AML Extend, you define one base Date model and extend it into different models for each role (Created Date, Delivered Date, etc.). This avoids the relationship conflict entirely.
### Implementation with `extend()`
Start with a base date model, then extend it into separate models for each role. Each extended model points to the same underlying table but has its own label and field names.
```aml title="dim_dates.model.aml"
// Base date model
Model dim_dates {
label: 'Dates'
type: 'table'
table_name: 'ecommerce.dates'
data_source_name: 'your_datasource_name'
dimension date {
label: 'Date'
type: 'date'
primary_key: true
}
dimension month {
label: 'Month'
type: 'text'
}
dimension quarter {
label: 'Quarter'
type: 'text'
}
}
```
```aml title="created_date.model.aml"
// Extend for different roles
Model created_date = dim_dates.extend({
label: 'Created Date'
dimension date {
label: 'Created At'
}
})
```
```aml title="delivered_date.model.aml"
Model delivered_date = dim_dates.extend({
label: 'Delivered Date'
dimension date {
label: 'Delivered At'
}
})
```
```aml title="cancelled_date.model.aml"
Model cancelled_date = dim_dates.extend({
label: 'Cancelled Date'
dimension date {
label: 'Cancelled At'
}
})
```
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
data_source_name: 'your_datasource_name'
models: [fct_orders, created_date, delivered_date, cancelled_date]
relationships: [
// Each extended model has its own active relationship
relationship(fct_orders.created_at > created_date.date, true),
relationship(fct_orders.delivered_at > delivered_date.date, true),
relationship(fct_orders.cancelled_at > cancelled_date.date, true)
]
}
```
### Querying with extended models
```aml
explore {
dimensions {
created_date.date,
delivered_date.date,
cancelled_date.date
}
measures {
fct_orders.order_count
}
}
```
**Result:** Users see separate date dimensions for each role, making it intuitive to choose the right date
### Key points for `extend()` approach
This approach offers:
- **Cleaner and more concise** - no need for `with_relationships()`
- **All relationships can be active** - avoids the one-active-relationship limitation
- **No duplication** - extends from a single base model
- Trade-off: **Too many date fields** - might confuse end-users when they want to apply date filter in dashboard
## Additional resources
- [Back to overview](/docs/modeling-patterns)
- [AML extend documentation](/reference/aml/extend)
- [`with_relationships()` reference](/reference/aql/with_relationships)
---
## Snowflake schema pattern
:::info
This article talks about **[snowflake schema pattern](https://www.ibm.com/docs/es/ida/9.1.0?topic=schemas-snowflake)**, and not to be confused with Snowflake (the data warehouse company).
:::
## What is snowflake schema?
Snowflake schema is a data modeling pattern where dimensions are normalized into multiple related tables, creating a more complex structure than star schema. When visualized, the normalized dimension tables branch out from the fact table like points on a snowflake.
Unlike star schema where each dimension is a single denormalized table, snowflake schema breaks dimensions into hierarchical relationships. For example, instead of storing product categories directly in the products table, you create separate tables:
- Products table links to Subcategories table
- Subcategories table links to Categories table
This creates a normalized hierarchy: **Fact → Product → Subcategory → Category**
**Conceptual visual:**
The branching structure of normalized dimensions creates the snowflake appearance - hence the name.
## Snowflake schema vs star schema
The key difference between these two patterns is how dimensions are structured. Here's a detailed comparison to help you choose the right approach:
| Aspect | Snowflake schema | Star schema |
|--------|------------------|-------------|
| **Dimension structure** | ✅ Normalized (multiple tables) | ❌ Denormalized (single table) |
| **Data redundancy** | ✅ Minimal redundancy | ❌ Category names repeated across products |
| **Schema complexity** | ❌ More tables and joins | ✅ Simpler structure |
| **Query performance** | ❌ More joins required | ✅ Fewer joins, faster queries |
| **Hierarchy support** | ✅ Natively supports hierarchies across multiple tables | ✅ Single-table hierarchies supported |
| **Report author experience** | ❌ More complex navigation | ✅ Easier to understand and use |
| **Maintenance** | ✅ Single source for updates | ❌ Updates across many rows |
| **Logical data model** | ✅ Clearer normalized structure | ⚠️ Less normalized |
**Recommendation:** In Holistics, we generally recommend **star schema** (denormalized dimensions) for better usability and performance, unless you have specific reasons to normalize.
## Step-by-step implementation
Let's build a complete snowflake schema for an e-commerce dataset with normalized product dimensions.
### Step 1: Create the fact model
Start with your fact table - this is the center of your schema storing measurable events.
```aml title="fct_sales.model.aml"
Model fct_sales {
type: 'table'
table_name: 'ecommerce.sales'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Sale ID'
type: 'number'
primary_key: true
hidden: true
}
dimension product_id {
label: 'Product ID'
type: 'number'
hidden: true
}
dimension customer_id {
label: 'Customer ID'
type: 'number'
hidden: true
}
dimension sale_amount {
label: 'Sale Amount'
type: 'number'
}
dimension quantity {
label: 'Quantity'
type: 'number'
}
measure total_sales {
label: 'Total Sales'
type: 'number'
definition: @aql sum(fct_sales.sale_amount);;
}
measure total_quantity {
label: 'Total Quantity Sold'
type: 'number'
definition: @aql sum(fct_sales.quantity);;
}
}
```
### Step 2: Create normalized product dimensions
Now create the product dimension hierarchy - this is where the "snowflake" structure comes in.
```aml title="dim_products.model.aml"
Model dim_products {
type: 'table'
table_name: 'ecommerce.products'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Product ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Product Name'
type: 'text'
}
dimension subcategory_id {
label: 'Subcategory ID'
type: 'number'
hidden: true
} // FK to subcategories
}
```
```aml title="dim_subcategories.model.aml"
Model dim_subcategories {
type: 'table'
table_name: 'ecommerce.product_subcategories'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Subcategory ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Subcategory'
type: 'text'
}
dimension category_id {
label: 'Category ID'
type: 'number'
hidden: true
} // FK to categories
}
```
```aml title="dim_categories.model.aml"
Model dim_categories {
type: 'table'
table_name: 'ecommerce.product_categories'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Category ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Category'
type: 'text'
}
}
```
### Step 3: Create customer location dimensions
Let's add another normalized dimension hierarchy for customer locations to create a more complete snowflake schema.
```aml title="dim_customers.model.aml"
Model dim_customers {
type: 'table'
table_name: 'ecommerce.customers'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Customer ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Customer Name'
type: 'text'
}
dimension city_id {
label: 'City ID'
type: 'number'
hidden: true
} // FK to cities
}
```
```aml title="dim_cities.model.aml"
Model dim_cities {
type: 'table'
table_name: 'ecommerce.cities'
data_source_name: 'your_datasource_name'
dimension id {
label: 'City ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'City'
type: 'text'
}
dimension country_id {
label: 'Country ID'
type: 'number'
hidden: true
} // FK to countries
}
```
```aml title="dim_countries.model.aml"
Model dim_countries {
type: 'table'
table_name: 'ecommerce.countries'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Country ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Country Name'
type: 'text'
}
}
```
### Step 4: Build the complete snowflake schema dataset
Now connect everything together. Notice how we have two normalized hierarchies: products and customer locations.
```aml title="snowflake_schema.dataset.aml"
Dataset ecommerce_snowflake {
label: 'E-commerce (Snowflake Schema)'
data_source_name: 'your_datasource_name'
models: [
fct_sales, // Fact table
dim_products, // Normalized product dimension
dim_subcategories, // Product hierarchy
dim_categories, // Product hierarchy
dim_customers, // Normalized customer dimension
dim_cities, // Customer location hierarchy
dim_countries // Customer location hierarchy
]
relationships: [
// Fact to dimensions
relationship(fct_sales.product_id > dim_products.id, true),
relationship(fct_sales.customer_id > dim_customers.id, true),
// Product hierarchy chain (snowflake)
relationship(dim_products.subcategory_id > dim_subcategories.id, true),
relationship(dim_subcategories.category_id > dim_categories.id, true),
// Customer location hierarchy chain (snowflake)
relationship(dim_customers.city_id > dim_cities.id, true),
relationship(dim_cities.country_id > dim_countries.id, true)
]
}
```
**Result:** You now have a complete snowflake schema with multiple normalized dimension hierarchies. Users can analyze sales by product category, subcategory, and customer location (city, country), even though the fact table only directly links to products and customers.
## When to use snowflake schema
Choose snowflake schema when you have these specific needs:
### 1. Source data is already normalized
If your source database uses normalized tables and you're modeling directly on top without a transformation layer (like dbt), you may need to work with the snowflake structure as-is.
### 2. Multiple dimension hierarchies exist
When you have several dimensions with natural hierarchies (products, geography, organizational structure) and want to maintain clear relationships.
### 3. Frequently changing hierarchy attributes
If category names or hierarchy attributes change often and you need a single source of truth, normalized tables make updates easier.
### 4. Shared across many facts
If the dimension hierarchy is used by many different fact tables, normalization ensures consistency and easier maintenance.
## Additional resources
- [Back to overview](/docs/modeling-patterns)
- [Star schema](/docs/modeling/modeling-patterns/star-schema)
- [Galaxy schema](/docs/modeling/modeling-patterns/galaxy-schema)
---
## Star schema pattern
## What is star schema?
Star schema is the most common and straightforward data modeling pattern where one fact table sits at the center, connected to multiple dimension tables radiating outward like points on a star.
**Visual structure:**
When you run a query like "Total revenue by product category last month":
- **Dimensions filter:** Products (category), Dates (last month)
- **Fact aggregates:** Orders (sum revenue)
## When to use star schema
Star schema works best when you have:
- A single primary business process to analyze (like orders, page views, or transactions)
- Need for simple, fast queries with minimal joins
- Performance as a key priority
You might want a different pattern if you're dealing with multiple related business processes ([Galaxy schema](/docs/modeling/modeling-patterns/galaxy-schema) is better for this) or have complex dimension hierarchies that benefit from normalization ([Snowflake dimensions](/docs/modeling/modeling-patterns/snowflake-dimensions)).
## Step-by-step implementation
### Step 1: Identify facts and dimensions
Start by classifying your models:
**Ask yourself:**
- "What am I measuring?" → **Fact models**
- "What am I grouping by?" → **Dimension models**
**Example classification:**
| Model | Type | Why? |
|-------|------|------|
| `fct_orders` | Fact | Measures order count, revenue, average order value |
| `dim_users` | Dimension | Describes customers (name, email, signup date) |
| `dim_products` | Dimension | Describes products (name, category, price) |
| `dim_dates` | Dimension | Describes time periods (date, month, quarter, year) |
### Step 2: Create the fact model
Your fact model is where the action happens - it stores the measurable events and metrics. Focus on defining clear measures and hiding technical fields like foreign keys that users don't need to see.
```aml title="fct_orders.model.aml"
Model fct_orders {
type: 'table'
table_name: 'ecommerce.orders'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Order ID'
type: 'number'
primary_key: true
hidden: true
}
// Foreign keys - link to dim_users model & hide from end users
dimension user_id {
label: 'User ID'
type: 'number'
hidden: true
}
dimension product_id {
label: 'Product ID'
type: 'number'
hidden: true
}
// Date field for time filtering
dimension created_at {
label: 'Order Date'
type: 'datetime'
}
// Status attribute for filtering
dimension status {
label: 'Order Status'
type: 'text'
}
// "Amount" dimension for aggregation
dimension amount {
label: 'Amount'
type: 'number'
}
// Measures belong in fact models
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(fct_orders.id) ;;
}
measure total_revenue {
label: 'Total Revenue'
type: 'number'
definition: @aql sum(fct_orders.amount);;
}
measure avg_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql avg(fct_orders.amount);;
}
}
```
### Step 3: Create dimension models
Dimension models provide the context for your analysis - the attributes you'll filter and group by. Each dimension needs a unique primary key, and should contain descriptive attributes that make sense to your users.
```aml title="dim_products.model.aml"
Model dim_products {
type: 'table'
table_name: 'ecommerce.products'
data_source_name: 'your_datasource_name'
dimension id {
label: 'Product ID'
type: 'number'
primary_key: true // ✅ Must be unique!
hidden: true
}
dimension name {
label: 'Product Name'
type: 'text'
}
dimension category {
label: 'Category'
type: 'text'
}
dimension price {
label: 'List Price'
type: 'number'
}
}
```
```aml title="dim_users.model.aml"
Model dim_users {
type: 'table'
table_name: 'ecommerce.users'
data_source_name: 'your_datasource_name'
dimension id {
label: 'User ID'
type: 'number'
primary_key: true
hidden: true
}
dimension name {
label: 'Customer Name'
type: 'text'
}
dimension email {
label: 'Email'
type: 'text'
}
dimension created_at {
label: 'Signup Date'
type: 'datetime'
}
}
```
### Step 4: Connect models in a dataset
Now bring everything together in a dataset by defining the relationships.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
label: 'E-commerce Analytics'
description: 'Core dataset for analyzing orders, users, and products using star schema design.'
data_source_name: 'your_datasource_name'
models: [
fct_orders, // Fact (center)
dim_users, // Dimension (point)
dim_products, // Dimension (point)
dim_dates // Dimension (point)
]
relationships: [
// Fact → Dimension (many-to-one)
relationship(fct_orders.user_id > dim_users.id, true),
relationship(fct_orders.product_id > dim_products.id, true),
relationship(fct_orders.created_at > dim_dates.date, true)
]
}
```
## Key rules for star schema
### Relationship rules
**1. Always many-to-one (fact → dimension)**
Relationships should always point from the fact table to the dimension table:
```aml
// Correct
relationship(fct_orders.user_id > dim_users.id, true)
// Wrong direction
relationship(dim_users.id > fct_orders.user_id, true)
```
**2. The "one" side must have unique primary key**
Make sure your dimension's primary key is actually unique:
```aml
Model dim_users {
...
dimension id {
label: 'User ID'
type: 'number'
primary_key: true // Must be unique!
}
}
```
**3. Hide foreign keys**
Technical fields like foreign keys should be hidden from end users:
```aml
Model fct_orders {
...
dimension user_id {
label: 'User ID'
type: 'number'
hidden: true // Hide technical fields
}
}
```
## Example queries
Once your star schema is set up, you can easily build queries:
**Total revenue by product category:**
```aql
explore {
dimensions {
dim_products.category
}
measures {
fct_orders.total_revenue
}
}
```
**Monthly orders by customer:**
```aql
explore {
dimensions {
dim_users.name,
fct_orders.created_at // Auto-groups by month
}
measures {
fct_orders.total_orders
}
}
```
## Next steps
Once you've mastered star schema, explore more advanced patterns:
- **[Galaxy schema](/docs/modeling/modeling-patterns/galaxy-schema)** - Multiple facts sharing dimensions
- **[Snowflake dimensions](/docs/modeling/modeling-patterns/snowflake-dimensions)** - Normalized dimension hierarchies
- **[Role-playing dimensions](/docs/modeling/modeling-patterns/role-playing-dimensions)** - Same dimension, multiple meanings
## Additional resources
- [Data models in Holistics](/docs/data-model)
- [Relationships overview](/docs/relationships)
- [Datasets overview](/docs/datasets/)
- [Handle path ambiguity](/docs/joins/path-ambiguity)
---
## Handle Non-additive Metrics
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Holistics Modeling Layer](/docs/modeling/)
- [Dimensions & measures](/docs/model-fields)
- [What is a Metric?](/as-code/aql/learn/what-aql-is-for)
:::
## What are additive metrics and non-additive metrics?
Non-additive metrics are calculations that can only be aggregated once, and cannot be aggregated further (or it will produce nonsensical results). To be more detailed:
- **Additive metrics** usually involve COUNT, SUM calculations. These metrics can be aggregated multiple times and still produce correct results.
- **Non-additive metrics** involve calculations like COUNT DISTINCT, AVERAGE, MEDIAN. Due to the nature of calculation, these metrics cannot be rolled up multiples times because it will produce incorrect results.
Let's take an example of an `orders` table where we're interested in reporting 2 key metrics: **Revenue** and **Active Users**.
```dbml
Table orders {
id integer [PK]
user_id integer
created_at datetime
bigint order_value
}
```
You can aggregate **Revenue** in multiple aggregation rounds (daily → monthly). But you cannot aggregate **Active Users** the same way.
In other words, in the below 2 SQL statements, the calculation of **monthly active users** (MAUs) is incorrect.
```sql
-- Aggregate daily
CREATE TABLE orders_daily AS
SELECT
created_at::date as date_d
SUM(order_value) as daily_revenue -- correct
COUNT(DISTINCT user_id) as daily_unique_users --correct
FROM orders
GROUP BY 1;
-- Aggregate monthly
CREATE TABLE orders_monthly AS
SELECT
DATE_TRUNC(date_d, 'MONTH')::date as month_d
SUM(daily_revenue) as monthly_revenue -- correct
SUM(daily_unique_users) as monthly_unique_users --incorrect
FROM orders_daily
GROUP BY 1
```
MAUs cannot be calculated from DAUs, but have to get directly from raw:
```sql
SELECT
DATE_TRUNC(created_at, 'MONTH')::date as month_d
COUNT(DISTINCT user_id) as monthly_unique_users --correct
FROM orders
GROUP BY 1
```
In this case, Revenue is an additive metric, and Active Users is a non-additive metric.
## How does Holistics modeling layer work with non-additive metrics?
Holistics modeling can work well with non-additive metrics when you have the raw data. Since Holistics can perform just-in-time SQL generation based on the list of fields the user select, instead of requiring pre-aggregation of data.
The above example can be modeled into Holistics modeling as:
```aml
Model orders {
type: 'table'
dimension id {
type: 'number'
}
dimension created_at {
type: 'datetime'
}
dimension user_id {
type: 'number'
}
measure unique_users {
type: 'number'
definition: @sql COUNT(DISTINCT {{user_id}}) ;;
}
}
```
And when the end-user explores the data, a corresponding SQL will be generated based on the selection of the user.
---
## Parameter Fields
## Introduction
**Parameter fields** (aka param fields) are a special type of field defined in models. They act as variables that **carry user inputs from dashboard controls into your model's query or field definitions**, dynamically changing chart results.

Unlike [dimensions & measures](/docs/model-fields) which contain actual data, parameter fields act as variables that:
- Accept user inputs through dashboard filters or visualization conditions
- Pass these values into your model's query definitions (SQL or AQL)
- Create dynamic content (charts) based on user's selection
## How it works
To use param fields, we:
1. Define param fields in model (can be table model or query model)
2. Reference to param fields in model's query, dimension or measure's definitions
3. Bind dashboard's filter to model's param fields
### Define Param Field
Add a parameter field to your model using this syntax:
```tsx
Model my_model {
dimension xxx { ... }
measure yyy { ... }
// highlight-start
param my_param {
label: 'Parameter Label' // How it appears in the UI
type: 'text' // Data type (text, number, datetime)
description: 'What this parameter does' // Helps other users understand the purpose
allowed_values: ['value1', 'value2'] // Optional: restrict to specific values
}
// highlight-end
}
```
Available parameter types:
- `text`: For string values
- `number`: For numeric inputs
- `datetime`: For date and time values
- `date`: For date values
- `truefalse`: For true/false options
### Reference Param Fields in Model
You can reference param fields in your model's query or dimension formulas.
```tsx title="Inject param fields to query model"
Model my_model {
type: 'query'
param my_param { ... }
query: @sql
SELECT *
FROM your_table
// highlight-next-line
WHERE {% filter(my_param) %} category {% end %} -- Injects filter condition from the 'my_param' parameter
;;
}
```
```tsx title="Inject param fields in field's definitions"
Model users {
dimension age_group { ... }
dimension gender { ... }
param dim_selector {
type: 'text'
description: 'Select Dimension'
allowed_values: ['age_group', 'gender']
}
dimension dynamic_field {
type: 'text'
// highlight-start
definition: @aql case(
when: 'age_group' in my_model.dim_selector, then: my_model.age_group,
when: 'gender' in my_model.dim_selector, then: my_model.gender
);
// highlight-end
}
}
```
### Apply Param Fields at Dashboard
In a dashboard, create a filter and bind it to the model's param field.
```tsx
Dashboard sales {
block f1: FilterBlock {
label: 'Select Dimension'
type: 'field'
source: FieldFilterSource {
dataset: my_dataset
// highlight-next-line
field: r(users.dim_selector)
}
default {
operator: 'is'
value: ['value1']
}
}
block f2: VizBlock {
}
}
```
### Handling Multiple Values in Param Fields
Param fields can receive multiple values from dashboard filters. However, some AQL definitions may only accept a single value.
**When your AQL expects a single value**, use the `first()` function to extract only the first value from the array:
```aml
Dataset your_dataset {
...
models: [categories, orders, param_model]
metric total_rev_cate_single {
definition: @aql total_revenue | where(categories.name == (param_model.category_param | first())) ;;
}
metric total_rev_date {
definition: @aql total_revenue | where(orders.created_at matches (param_model.date_param | first())) ;;
}
}
```
By doing this, even if a user passes multiple values to the param field via a dashboard filter, Holistics will only use the first value in the calculation.
**When your AQL can handle multiple values**, you don't need `first()` - use the `in` operator instead:
```aml
Dataset your_dataset {
...
models: [categories, orders, param_model]
metric revenue_cate_multiple {
definition: @aql total_revenue | where(categories.name in [param_model.category_param]) ;;
}
}
```
## Key Use Cases
### 1. Dynamic Query Models
Parameter fields can pass user input values directly into the SQL definition of a Query Model. This allows for:
- **Dynamic column selection**: Let users choose which columns to include in a query
- **Performance optimization**: Apply filters at the SQL level rather than after data retrieval
- **Customizable calculations**: Allow users to input values for calculations (e.g., exchange rates)
For detailed implementation of this use case, see [Dynamic Query Model](/docs/query-parameters).
### 2. Dynamic Dimension Selection
Parameter fields enable users to change which dimension is used in a report through dashboard filters. This allows for:
- **Flexible grouping**: Switch between different grouping dimensions (e.g., by product, by region, by time period)
- **User-controlled analysis**: Let users decide how to slice the data without creating multiple reports
- **Simplified dashboards**: Reduce the number of visualizations needed by making existing ones adaptable
For detailed implementation, see [Dynamic Dimensions](/docs/modeling/dynamic-dimensions).
### 3. Dynamic Measures Selection
Similar to dimensions, parameter fields can control which metrics are displayed in a report:
- **Comparative analysis**: Switch between different metrics for comparison
- **Contextual reporting**: Show metrics relevant to specific business contexts
- **User preference**: Allow users to focus on metrics they care about
For detailed implementation, see [Dynamic Measures](/docs/modeling/dynamic-measures).
### 4. Dynamic Explore Conditions
Parameter fields can control the filtering conditions applied to your data:
- **Adaptive security filters**: Apply different data access rules based on user roles
- **Flexible time windows**: Dynamically adjust date ranges based on user selection
- **Contextual filtering**: Apply different business rules based on selected parameters
For detailed implementation, see [Dynamic Explore Conditions](/docs/modeling/dynamic-conditions).
## Best Practices
- **Provide clear labels and descriptions** for parameters so users understand their purpose
- **Set appropriate default values** to ensure reports work even before user input
- **Consider performance implications** when using parameters in complex queries
- **Test thoroughly** with different parameter values to ensure correct behavior
- **Document parameter usage** for other analysts who might use your models
## Related Documentation
- [Dynamic Query Model](/docs/query-parameters)
- [Dynamic Dimensions](/docs/modeling/dynamic-dimensions)
- [Dynamic Measures](/docs/modeling/dynamic-measures)
- [Dynamic Explore Conditions](/docs/modeling/dynamic-conditions)
---
## Query Model Persistence
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Query Models](/docs/query-models)
- [AML Persistence Reference](/reference/aml/persistence)
:::
## Introduction
Query Model Persistence feature allows you to write the result of your Query Model to your database to either make it available for querying with an external tool, or to improve performance when exploring data in Holistics.
:::info
Ensure the database user connected to Holistics has **WRITE/CREATE permissions** on the target schema defined in your persistence block.
Without this, the build job will fail, and Holistics will revert to executing the live SQL query.
:::
## High-level workflow
In general, here are the steps to enable Persistence for your Query Model:
1. Add `persistence` config to your model
2. Create a persistence schedule using the `schedules.aml` file
3. Deploy your AML project to production for the persistence to take effect.
There are also optional steps like:
- [Config cascading persistence behavior](#flow-based-cascading-persistence)
- [Optimize persisted tables](#persistence-table-optimizations)
**How it works:** At the intervals specified in the `schedules.aml` file, Holistics will execute the query model and write the result set to your database, while taking into account the configurations in the `persistence` config.
We will go into details in the sections below.
### 1. Add `persistence` config
The `persistence` config is added to the model definition as follows:
```aml
Model orders {
type: 'query'
label: "Orders"
data_source_name: 'demodata'
owner: 'demo@holistics.io'
models: [orders]
query: @sql
SELECT {{ #orders.* }} FROM {{ #orders }}
WHERE {{ #orders.status }} = 'cancelled'
;;
// Add persistence config here
// highlight-start
persistence: FullPersistence {
schema: 'persisted'
view_name: 'cancelled_orders'
}
// highlight-end
dimension id {
label: 'Id'
type: 'number'
}
// dimension ...
}
```
Currently there are two types of persistence: `FullPersistence` and `IncrementalPersistence`. We will go into more details in the [Types of Persistence](#types-of-persistence-config) section.
### 2. Create persistence schedule
To set schedules to run persistences, you will need to create a `schedules.aml` file **in the root of your AML project**.
Schedule definition syntax is as follows:
```aml
const schedules = [
// Schedule orders model to run every 10 minutes
Schedule { models: [orders], cron: '0,10,20,30,40,50 * * * *' }
// We can define another schedule using a different interval
Schedule { models: [cancelled_orders], cron: '0 * * * *' }
// We can also set multiple models to use the same schedule
Schedule { models: [delivered_orders, cancelled_orders], cron: '0 * * * *' }
]
```
:::tip Cron schedule expression
Here are a few links to help you get used to cron schedule expression:
- https://crontab.guru: translate cron expression to natural language
- https://crontab.guru/examples.htm: examples of frequently used expressions
:::
### 3. Deploy to production
After creating or modifying persistence config and schedules, you will need to **commit the changes and deploy to production** for the new persistence to take effect.
## Monitoring Persistence Jobs
1. Go to [Job Monitoring](/docs/monitoring/job-monitoring)
2. Select relevant **Job Status**
3. Set **Source Type** to `ModelPersistenceTask`
4. Click **Refresh**
## Types of persistence config
In general, a basic persistence config has the following form:
```aml
persistence: PersistenceType {
schema: 'schema_name'
view_name: 'view_name'
// Other parameters
...
}
```
Explanation:
- `schema`: Schema name that the persisted table will be written into
- `view_name` (optional): If specified, Holistics will create a view with a consistent name in your database schema, allowing you to reliably query it outside of Holistics.
:::info Note
By default, each execution of a persistence job generates new names for persisted tables, as these tables function as a caching layer to boost query performance and are not intended for direct reuse.
If you want to reuse tables created through Holistics Persistence, we recommend specifying the `view_name` and using that view instead of the persisted table
:::
We will go into more details with each persistence type.
:::tip
Please check out the [AML Persistence](/reference/aml/persistence) document for full syntax reference.
:::
### Full Persistence
Full Persistence rebuilds the whole table each time it is triggered.
The Full Persistence config is specified as follows:
```aml
Model cancelled_orders {
type: 'query'
label: 'Cancelled Orders'
data_source_name: 'demodb'
owner: 'demo@holistics.io'
models: [orders]
query: @sql
SELECT {{ #orders.* }} FROM {{ #orders }}
WHERE {{ #orders.status }} = 'cancelled'
;;
// highlight-start
persistence: FullPersistence {
schema: 'persisted'
view_name: 'cancelled_orders'
}
// highlight-end
dimension id {...}
...
}
```
### Incremental Persistence
With Incremental Persistence, Holistics will execute the query, compare the current result set with the existing table, then append new records to the table instead of rebuilding it completely.
The Full Persistence config is specified as follows:
```aml
Model cancelled_orders {
type: 'query'
label: 'Cancelled Orders'
data_source_name: 'demodb'
owner: 'demo@holistics.io'
models: [orders]
query: @sql
SELECT {{ #orders.* }} FROM {{ #orders }}
WHERE {{ #orders.status }} = 'cancelled'
;;
// highlight-start
persistence: IncrementalPersistence {
schema: 'persisted'
view_name: 'cancelled_orders'
incremental_column: 'updated_at'
primary_key: 'id'
}
// highlight-end
dimension id {...}
...
}
```
There are two new parameters:
- `incremental_column`: Values of this column will be used to check for new records
- `primary_key` (optional): **Only use this option when your past records change.** Holistics will refer to this column to replaced changed records in the current persisted table.
#### How Incremental Persistence works
**Step 1: Query new data**
When the incremental persistence is triggered, the actual generated query will be:
```sql
SELECT * FROM ( /* full model query */ )
WHERE [[ incremental_column > {{ max_value }} ]]
```
Explanation:
- `WHERE [[ incremental_column > {{ max_value }} ]]` is the condition which Holistics insert to the query to only select newly created records.
- `max_value` is found from the `incremental_column`.
This query returns new data that will be persisted into the destination table later.
:::info Note
Currently, this filtering condition cannot be customized. In the future, we might allow users to customize their own incremental filtering conditions.
:::
**Step 2: Delete updated rows in destination table**
If `primary_key` is specified, Holistics will delete the existing rows in destination table that have the same `primary_key` values with the rows returned by step 1.
**Step 3: Insert new data into destination table**
Lastly, Holistics will insert new data (returned by step 1) into the destination table.
## Flow-based (Cascading) Persistence
### How it works
With Flow-based (Cascading) Persistence, if the parents of your current models are also persisted, they can also be triggered automatically (without the need to specify a separate schedule for them).
Let say you have the following model setup with dependencies:
```aml
Model model_a {
type: 'query'
query: @sql SELECT ... ;;
persistence: FullPersistence {...}
}
Model model_b {
type: 'query'
models: [model_a]
query: @sql SELECT {{ #a.field_name }} FROM {{ #model_a a }} ;;
persistence: FullPersistence {...}
}
Model model_c {
type: 'query'
models: [model_a]
query: @sql SELECT {{ #a.field_name }} FROM {{ #model_a a }} ;;
persistence: FullPersistence {...}
}
Model model_d {
type: 'query'
models: [model_b, model_c]
query: @sql SELECT {{ #b.field_name }}, {{ #c.field_name }}
FROM {{ #model_b b }}
LEFT JOIN {{ #model_c c }} on {{ #b.join_key }} = {{ #c.join_key }}
;;
persistence: FullPersistence {...}
}
```
To trigger the persistence of all models in the dependency chain, you only need to set the schedule for the final model:
```aml
// schedules.aml
const schedules = [
Schedule { models: [model_d], cron: '0,10,20,30,40,50 * * * *' }
]
```
:::info Notes
- Each persistence will follow the persistence config of that model.
- if `on_cascade` behavior is not specified, the `rebuild` option will be used.
:::
We will go into more details on the build options in the following section.
### `on_cascade` options
To specify the behavior of a model persistence when being **triggered by a downstream model**, we add the `on_cascade` parameter in the persistence config:
```aml
persistence: FullPersistence {
schema: 'persisted'
view_name: 'cancelled_orders'
// highlight-next-line
on_cascade: 'rebuild'
// on_cascade: 'reuse'
}
```
There are two options:
- `rebuild` (default): the persisted table of the model will be rebuilt completely when its persistence is triggered by a downstream model
- `reuse`: the persisted table will be reused
**When to use each option:**
- `rebuild` option should be used if data of a parent model change, and you want to reflect the change in the child models.
- `reuse` option should be used if:
- The model’s data is static
- You want to control the exact timing of the persistence process. E.g: enable reuse and add a schedule to refresh at 6AM everyday ⇒ it will only be rebuilt once everyday at that time, regardless of other cascading.
:::caution
If a parent model has a schedule in `schedules.aml` file, a normal persistence job will be triggered beside the cascading trigger coming from the child model. This job will follow the model's persistence config (`FullPersistence` or `IncrementalPersistence` and disregard the `on_cascade` options.)
:::
### Example on build behaviors
Let's say we have the following setup:
- `model_b` uses `on_cascade: 'reuse'` option.
- `model_a, model_c, model_d` use `on_cascade: 'rebuild'` option.
```aml
Model model_a {
type: 'query'
persistence: FullPersistence {
schema: 'persisted'
// highlight-next-line
on_cascade: 'rebuild'
}
}
Model model_b {
type: 'query'
models: [model_a]
persistence: FullPersistence {
schema: 'persisted'
// highlight-next-line
on_cascade: 'reuse'
}
}
Model model_c {
type: 'query'
models: [model_a]
persistence: FullPersistence {
schema: 'persisted'
// highlight-next-line
on_cascade: 'rebuild'
}
}
Model model_d {
type: 'query'
models: [model_b, model_c]
persistence: FullPersistence {
schema: 'persisted'
// highlight-next-line
on_cascade: 'rebuild'
}
}
```
We also have the following schedules:
```aml
// schedules.aml
const schedules = [
// Schedule 1: trigger model_d
Schedule { models: [model_d], cron: ... }
// Schedule 2: trigger model_b
Schedule { models: [model_b], cron: ... }
// Schedule 3: trigger model_a
Schedule { models: [model_a], cron: ... }
]
```
The behavior of the persistence will be as follows:
- With **Schedule 1**, persistence of `model_d` is triggered, `model_a` and `model_c` are rebuilt. `model_b` is reused.
- With **Schedule 2**, persistence of `model_b` is triggered using `FullPersistence` (`on_cascade` option is ignored). Persistence of `model_a` is triggered (`on_cascade: 'rebuilt'` is respected).
- With **Schedule 3**, persistence of `model_a` is triggered, and `model_a` is rebuilt.
## Persistence Table Optimizations
Model Persistence write the result set of your SQL into a **physical tables** in your Data Warehouse. This means you can apply table optimization features provided by your Data Warehouse to further improve the query performance on the persisted tables.
For example, a Data Warehouse may provide these features:
* Indexing ([Postgresql](https://www.postgresql.org/docs/current/sql-createindex.html), [ClickHouse](https://clickhouse.com/docs/en/optimize/skipping-indexes), [MySql](https://dev.mysql.com/doc/refman/8.3/en/create-index.html), [SqlServer](https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql), etc.)
* Clustering ([Snowflake](https://docs.snowflake.com/en/user-guide/tables-clustering-keys#defining-a-clustering-key-for-a-table), [BigQuery](https://cloud.google.com/bigquery/docs/creating-clustered-tables), etc.)
* Partitioning ([BigQuery](https://cloud.google.com/bigquery/docs/creating-partitioned-tables), [Postgresql](https://www.postgresql.org/docs/current/ddl-partitioning.html), etc.)
* SORTKEY, DISTKEY ([Redshift](https://docs.aws.amazon.com/redshift/latest/dg/r_CREATE_TABLE_NEW.html))
* Table Engines ([ClickHouse](https://clickhouse.com/docs/en/engines/table-engines), [MySql](https://dev.mysql.com/doc/refman/8.3/en/storage-engines.html), etc.)
* etc.
Such features can be applied using **Custom Persistence DDL**.
### Custom Persistence DDL
#### Usage
By declaring the `custom_ddl` (Custom Data Definition Language) in a persistence config, you can customize the way the persistence table is created in your Data Warehouse.
For example, given this query model:
```aml
Model orders_fact {
type: 'query'
query: @sql
select
{{#o.id}} as order_id
, {{#o.user_id}}
, {{#o.status }}
, {{#o.created_at}} as order_created_at
, {{#o.delivery_attempts}}
, {{#o.discount}}
, {{#oi.quantity}}
, {{#p.id}} as product_id
, {{#p.price}}
, {{#p.merchant_id}}
from {{ #ecommerce_order_items AS oi }}
left join {{ #ecommerce_orders AS o }} on {{#oi.order_id}} = {{#o.id}}
left join {{ #ecommerce_products AS p }} on {{#oi.product_id}} = {{#p.id}}
;;
// other Model configs...
}
```
We can declare a `custom_ddl` like this to add **indexes** to the persisted table:
```aml
Model orders_fact {
type: 'query'
query: @sql
/* see sql above */
;;
persistence: FullPersistence {
schema: 'persisted'
// BEGIN custom_ddl declaration
custom_ddl: @sql
/* Run parsed_query and persist it into persisted_table */
CREATE TABLE {{ persisted_table }}
AS {{ parsed_query }};
/* Add indexes to the persisted_table */
CREATE INDEX {{ index_name }} ON {{ persisted_table }} (order_created_at);
CREATE INDEX {{ index_name }} ON {{ persisted_table }} ((order_created_at::date));
CREATE INDEX {{ index_name }} ON {{ persisted_table }} (user_id);
CREATE INDEX {{ index_name }} ON {{ persisted_table }} (status);
CREATE INDEX {{ index_name }} ON {{ persisted_table }} (product_id);
CREATE INDEX {{ index_name }} ON {{ persisted_table }} (merchant_id);
CREATE INDEX {{ index_name }} ON {{ persisted_table }} (order_id, product_id);
;;
// END custom_ddl declaration
}
```
Let's break down the `custom_ddl` AML parameter:
* It is a SQL DDL string and hence should be declared using `@sql` syntax.
* The exact syntax/dialect corresponds to the Data Warehouse of the Model. For example, if your Model's source is Postgresql, you need to use [Postgresql DDL dialect](https://www.postgresql.org/docs/current/sql-createtable.html).
* It provides these template variables:
Variable | Description
-------------- | ------------
`parsed_query` | The final query/SQL generated from the Model.
`persisted_table` | Name of the physical table where the Model will be persisted into.Your DDL **must** use this table name so that Holistics can query it properly.
`index_name` | A uniquely generated index name. It will be unique for _every instance_ of usage.This helps you conveniently create many indexes without having to worry about index name collision.
For the above example `custom_ddl`, Holistics will run this query when executing the Model Persistence:
```sql
/* Run parsed_query and persist it into persisted_table */
CREATE TABLE "persisted"."order_master__H518f24_T1923344"
AS select
"o"."id" as order_id
, "o"."user_id"
, "o"."status"
, "o"."created_at" as order_created_at
, "o"."delivery_attempts"
, "o"."discount"
, "oi"."quantity"
, "oi"."product_id"
, "p"."price"
, "p"."merchant_id"
, "oi"."order_id" as test_broken
from (
SELECT
"ecommerce_order_items"."quantity" AS "quantity",
"ecommerce_order_items"."product_id" AS "product_id",
"ecommerce_order_items"."order_id" AS "order_id"
FROM
"ecommerce"."order_items" "ecommerce_order_items"
) AS "oi"
left join (
SELECT
"ecommerce_orders"."id" AS "id",
"ecommerce_orders"."user_id" AS "user_id",
"ecommerce_orders"."status" AS "status",
"ecommerce_orders"."created_at" AS "created_at",
"ecommerce_orders"."delivery_attempts" AS "delivery_attempts",
"ecommerce_orders"."discount" AS "discount"
FROM
"ecommerce"."orders" "ecommerce_orders"
) AS "o" on "oi"."order_id" = "o"."id"
left join (
SELECT
"ecommerce_products"."price" AS "price",
"ecommerce_products"."merchant_id" AS "merchant_id",
"ecommerce_products"."id" AS "id"
FROM
"ecommerce"."products" "ecommerce_products"
) AS "p" on "oi"."product_id" = "p"."id";
/* Add indexes to the persisted_table */
CREATE INDEX idx_7bfbea69fc9f7ad857259e007c5ebba7 ON "persisted"."order_master__H518f24_T1923344" (order_created_at);
CREATE INDEX idx_65bca19291cce8af52e3c56326a6d673 ON "persisted"."order_master__H518f24_T1923344" ((order_created_at::date));
CREATE INDEX idx_c1cb8848fe960f11867dca397eeec9d5 ON "persisted"."order_master__H518f24_T1923344" (user_id);
CREATE INDEX idx_2043e1826d0198f8eb310900e11a20ae ON "persisted"."order_master__H518f24_T1923344" (status);
CREATE INDEX idx_684ba1e7d8dbaa8ec7c1b4279e22be64 ON "persisted"."order_master__H518f24_T1923344" (product_id);
CREATE INDEX idx_7a74a60d164ccc9a2ed726c63c90d2ec ON "persisted"."order_master__H518f24_T1923344" (merchant_id);
CREATE INDEX idx_cfe6ad4d54928ec97888505ccc0bf6db ON "persisted"."order_master__H518f24_T1923344" (order_id, product_id);
```
#### Using templates
In Holistics AML editor, after typing `custom_ddl: ` (with the colon), use `Ctrl + Space` to list and use the built-in `custom_ddl` templates.
#### Supported Data Warehouses
Currently, Custom Persistence DDL supports these Data Warehouses:
* BigQuery
* ClickHouse
* MySql
* Postgresql
* Redshift
* Snowflake
* SqlServer
#### Incremental Persistence Caveat
`custom_ddl` is only applied when **creating** the persistence table.
Thus, for Incremental Persistences, it will only be executed once **in the first run** of the Incremental Persistence.
As a result, **updating** the `custom_ddl` of an (already-persisted) Incremental Persistence **won't have any effect**.
To effectively change how the table of an Incremental Persistence is created, the existing persisted table must be dropped first. In future releases of Holistics, we will try to make it more convenient to reset the table creation of Incremental Persistences.
## Other notes
### Delay in persistence job execution
The persistence may not run at the exact time intervals that you set due to:
- [Flow-based (cascading) persistence](#flow-based-cascading-persistence).
- [Not having enough workers for persistence jobs](/docs/jobs/queues-and-workers#default-slots-for-specific-job-queues). Persistence jobs are assigned to the **Data Transform** queue.
### Smallest possible run frequency
The smallest possible run frequency is **5 (five) minutes.** This is because we scan cron every five minutes. Even if you schedule a persistence to run every one minute, it will still run at every five minutes.
### Trigger-based execution is currently not supported
A common scenario where users want to trigger persistence process is when the result of the model's SQL returns a new value comparing to the existing values. However, this feature is not supported yet in holistics.
### When will the persisted table be invalidated?
The Persisted table will be automatically invalidated and removed in the following scenarios:
- **When persistence config and related schedule is removed from production.** The persisted tables will be retained for 24 hours before being removed.
- **When the current model or upstream dependencies changed.** This includes a change to query, incremental column, primary_key, model name, etc.
---
## Dynamic Query Model
:::tip knowledge checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Query Model](/docs/query-models)
- [Parameter Fields](/docs/modeling/param-fields)
:::
## Introduction
By default, when you filter data when exploring a data model via the Exploration UI or via a Dashboard, what happens behind the scene is as follows:
1. Your model is compiled into a full SQL statement.
2. The SQL is run against your database and returns a result set
3. The filtering condition is applied **on the result set**, and the final result is displayed to you.
However, there are cases in which you require the filtering condition to be **included in the model's SQL in step 1** instead of being applied only in step 3.
## Example use cases
- To draw a histogram representing the revenue distribution of customers from city X, you will need to calculate revenues of customers from city X first, and then have a second aggregation to group their revenue values into various bins and calculate bin size. The filter condition `WHERE city = 'X'` needs to be included in the first aggregation.
- In an international company, to calculate and display monetary values (like revenue) in different currencies, it is intuitive to allow end-users to freely insert exchange rate into the calculation from the familiar dashboard interface. With only static SQL, you will need to aggregate the revenue into one currency and then cross-join the result with an exchange rates table to intentionally create a "fan-out" situation. This is often a computationally expensive operation.
These complex analytics use cases can now be solved efficiently with Holistics's **Query Parameters** feature.
## How it works
For example, you have an `order_items_aggr` model which counts how many orders have a certain number of items:
```aml title=order_items_aggr.model.aml
Model order_items_aggr {
...
query: @sql
with aggr as (
select
oi.order_id
, count(*) as items_count
from ecommerce.order_items oi
left join ecommerce.orders o on oi.order_id = o.id
group by 1
)
select
items_count
, count(*) as orders_count
from aggr
group by 1
;;
}
```
```aml title=order_items_aggr.model.aml
Model order_items_aggr {
...
query: @sql
with aggr as (
select
oi.order_id
, count(*) as items_count
from ecommerce.order_items oi
left join ecommerce.orders o on oi.order_id = o.id
// highlight-next-line
where {% filter(order_date_param) %} o.created_at {% end %}
group by 1
)
select
items_count
, count(*) as orders_count
from aggr
group by 1
;;
// Query param declaration
param order_date_param {
label: 'Order Created At'
type: 'datetime'
}
}
```
If you only want to check orders created on the date `'2024-10-01'`, by default this is not possible because the final model does not have an "Order Created At" field to filter on. With Query Parameters, you can insert the filter condition inside the SQL:
```sql
# order_items_aggr model
with order_items_aggr as (
with aggr as (
select
oi.order_id
, count(*) as items_count
from ecommerce.order_items oi
left join ecommerce.orders o on oi.order_id = o.id
group by 1
)
select
items_count
, count(*) as orders_count
from aggr
group by 1
)
select
items_count,
orders_count
from order_items_aggr
# Not possible to filter orders by date
```
```sql
# order_items_aggr model
with order_items_aggr as (
with aggr as (
select
oi.order_id
, count(*) as items_count
from ecommerce.order_items oi
left join ecommerce.orders o on oi.order_id = o.id
# date filter from dashboard is applied INSIDE of the CTE
// highlight-next-line
where date(o.created_at) = '2024-10-01'
group by 1
)
select
items_count
, count(*) as orders_count
from aggr
group by 1
)
select
items_count,
orders_count
from order_items_aggr
```
In the following section, we will walk you through the steps to set up and use query parameters.
## Set up
To use Query Parameters in your analytics setup, you need to declare it in the Query Model first, and then (optionally) link your dashboard filters to the Query Parameters.
### 1. Declare Query Parameters in Query Model
To use Query Parameters in a Query Model, you will need to:
1. Declare the parameters in the model definition with `param` parameter
2. Incorporate the query parameter in the main SQL using the `{% filter(param) %} column_name {% end %}` expression as "placeholder" for the incoming filter.
In the example above, we have the following model definition:
```aml title=order_items_aggr.model.aml
Model order_items_aggr {
...
// Query param declaration
param order_date_param {
label: 'Order Created At'
type: 'datetime'
}
query: @sql
with aggr as (
select
oi.order_id
, count(*) as items_count
from ecommerce.order_items oi
left join ecommerce.orders o on oi.order_id = o.id
// Placeholder for end-user filter
// highlight-next-line
where {% filter(order_date_param) %} o.created_at {% end %}
group by 1
)
select
items_count
, count(*) as orders_count
from aggr
group by 1
;;
}
```
When you include your model in a dataset, you will see the parameter appears under the **Fields** list. You can drag it into the **Conditions** area, and use it similarly to how you use a filter with normal fields.
:::tip
The full list of Query Model's parameters is documented in the [AML Models - Query Model](https://docs.holistics.io/reference/aml/query-model) reference page.
:::
### 2. Link Dashboard Filters to Query Parameters
If you need to interact with a Query Parameter from a dashboard, the process is similar to how you create a filter from a dataset field:
## Advanced use cases
### I. Access the direct value of a query parameter
Query Parameters in Holistics support two different syntaxes for accessing parameter values, each serving different use cases:
| | Filter Syntax | Direct Value Syntax |
|--------|---------------|------------------|
| Syntax | `{% filter(param) %} column_name {% end %}`| `{{ param }}` |
| Use Cases | - Best for standard filtering operations- Handle multiple filter values automatically- Skip filtering when no value is provided | - Perform custom calculations with the parameter value- Create complex SQL conditions- Have full control over how the value is used |
#### Example: Date Range Filtering
Let's modify our previous example to use direct parameter values for a custom date range filter:
```aml title=order_items_aggr.model.aml
Model order_items_aggr {
...
param order_start_date {
label: 'Order Start Date'
type: 'date'
}
param order_end_date {
label: 'Order End Date'
type: 'date'
}
query: @sql
with aggr as (
select
oi.order_id
, count(*) as items_count
from ecommerce.order_items oi
left join ecommerce.orders o on oi.order_id = o.id
// Using direct parameter values for custom date range logic
// highlight-next-line
where o.created_at > {{ order_start_date }}
// highlight-next-line
and o.created_at < {{ order_end_date }}
group by 1
)
select
items_count
, count(*) as orders_count
from aggr
group by 1
;;
}
```
This generates the following SQL (omitted for brevity):
```sql
where o.created_at > '2024-01-01'
and o.created_at < '2025-01-02'
```
:::caution Considerations
When using the Direct Value Syntax:
- You must provide default values in the dashboard filter to handle cases where no value is selected, otherwise the SQL will break.
- Only the first value will be used if multiple values are provided in the filter
:::
---
## Model Field Expression
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Holistics Expression](/docs/expression/intro.md)
:::
## Syntaxes
While Holistics uses SQL as our underlying, final query language, we extend SQL to add our own little language on top. This page talks about the different syntax we put on top of SQL, and how they will be translated back to SQL.
### Querying Models & Fields
In SQL queries (mostly [Query Models](/docs/query-models.md)), instead of querying physical tables, you can query the model instead using this syntax.
Action
Syntax
Refer to a model
{{ #model_name }}
Refer to a model (with alias)
\{\{ #model_name alias \}\}
(place alias inside the curly brackets)
Refer to a field (dimension or measure)
\{\{ #model_name.field_name \}\}
\{\{ #alias.field_name \}\}
Get all fields
\{\{ #model_name.* \}\}
\{\{ #alias.* \}\}
```sql
with val as (
select
{{ #oi.order_id }}
, {{ #oi.quantity }} * {{ #p.price }} as total_value
from {{#ecommerce_order_items oi}}
left join {{#ecommerce_products p}} on {{ #oi.product_id }} = {{ #p.id }}
)
select
{{ #o.* }}
, val.total_value
from {{#ecommerce_orders o}}
left join val on {{ #o.id }} = val.order_id
```
Behind the scenes, our query engine will parse the syntaxes into a full, valid SQL query to run against your database. Referred models will be turned into CTEs or a persisted table (if persistence is turned on). Referred custom fields/measures will be turned into the full formula.
It is **recommended** that you use models all the time for consistency and ensure dependency between SQL models.
### `{{#THIS.field}}` syntax usage
:::info
This syntax is only available in Holistics 3.0.
:::
When defining custom dimensions or measures, to refer to a dimension or measure in the same model, the syntax is: `{{ #THIS.field_name }}` or `{{ #THIS.measure_name }}`.
`THIS` keyword is an alias to refer to the current model.
### `{{#SOURCE.column_name}}` syntax usage
:::info
This syntax is mostly used to create [4.0 Field definitions](/reference/aml/field).
:::
`{{ #SOURCE.column_name }}` references a column in the table that is connected to the table model you’re working on.
`SOURCE` keyword is an alias to refer to the current table underlying the model.
```json
// AML Syntax
dimension email {
label: 'email
type: 'text'
// refer to the email column in your underlying table
definition: @sql {{#SOURCE.email}};;
}
```
## Using model syntax for better query performance
When you are selecting from a [Table Model](/docs/table-models.md), or a persisted [Query Model](/docs/query-models.md), if you use Holistics's field reference syntax, Holistics will be able to select only the necessary fields to be inserted into the CTE.
If normal SQL syntax is used, the engine will need to insert **all the fields in the base table** into the CTE.
For example, selecting from a Table Model created from a table with more than 20 fields, using normal SQL syntax:
```sql
select
id
, name
, property_type
, room_type
from {{#homestay_listings}}
```
The resulting query will include all the fields:
If you use Holistics's syntax:
```sql
select
{{#l.id}}
, {{#l.name}}
, {{#l.property_type}}
, {{#l.room_type}}
from {{#homestay_listings l}}
```
This is particularly important when you query from "fat tables" with large number of columns (like Snowplow event tables).
---
## Activity logs
## Introduction
The Activity Logs Dashboard is a realtime audit trail that documents each user’s activities, including what resources they have viewed/ made changes to, timestamp of dates and times and even their IP addresses. You can also use the built-in filter to query your desired log.
## How to access
To access Activity Logs, go to
menu Menu -> list Activity Logs.
## Filtering activities
### Action filter
All recorded users' activities will fall under one of our pre-set categories, which is helpful when you want to search by action type using this filter.
Below is the list of all recorded activity types in Holistics.
No.
Activity
1
View report
2
Submit report
3
Update report
4
Create report
5
Destroy report
6
Export report
7
View dashboard
8
Login
9
Activate account
10
Add user to group
11
Remove user from group
13
Create user
12
Invite user
14
View dataset
15
Create dataset
16
Update dataset
17
Delete dataset
### Target class filter
This filter will help you scope down all operations that have been done against a particular target group.
Below is the list of all target classes in Holistics.
No.
Target Class
1
User
2
Group
3
Query Report
4
Email Schedule
5
Dashboard
6
Report Category
7
Shared Filter
8
Dataset
## Data retention period
Please refer to our [Data Retention Policy](/docs/security-compliance/data-retention) for more information.
---
## Introduction(Monitoring)
With the [Admin](/docs/admin/user-roles#role-permissions-matrix) role, you get a set of built-in tools to monitor operations across your resources: who's active, how dashboards are used, and how jobs run.
:::tip Note
**Enterprise clients** can get more specific and detailed reports on a regular basis. Please contact support@holistics.io for more information.
:::
## Monitoring tools
These dashboards are available out of the box.
A realtime audit trail of every operation your users carry out: what they viewed or changed, when, and from which IP address.
High-level usage metrics that show how your users engage with your dashboards.
A record of every job executed against your resources, with timestamps, status, and the user who triggered each one.
Analyze how your dashboards perform across the workspace and find areas to optimize.
## Data retention period
Refer to the [data retention policy](/docs/security-compliance/data-retention) for how long monitoring data is kept.
---
## Job monitoring
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Job Queues and Workers](/docs/jobs/queues-and-workers)
:::
## Required permissions
:::info Notes
This page is accessible by **Admins** and **Analysts** only.
Learn more about user roles [here](/docs/admin/user-roles).
:::
* **Admins** can monitor **all jobs** within the Holistics workspace.
* **Analysts** can monitor jobs on **permitted Data Sources**.
* To permit Analysts to monitor jobs on a specific Data Source:
1. Go to [Data Source management page](/docs/data-source-management)
2. Click **Share**
3. Add permission with Action **Monitor Jobs** for your Analysts
* Analysts **cannot** monitor [Job Queues & Workers](#monitoring-job-queues--workers).
* Analysts **cannot** view [Job Performance Analytics](#job-performance-analytics).
## How to access
To access Job Monitoring, go to
menu Menu -> monitor Jobs Monitoring.
Or you can use the direct link corresponding to your workspace's domain. Here are the links for the common domains:
* APAC: https://secure.holistics.io/manage/jobs
* US: https://us.holistics.io/manage/jobs
* EU: https://eu.holistics.io/manage/jobs
## Monitoring jobs
The **Jobs** tab displays in realtime every job executed in your Holistics workspace.
It includes helpful details such as:
- **Source** of the job
- **Timestamps** (creation, start, end)
- **Durations**
- Pending duration: How long the job waited in the queue
- Running duration: How long the job ran
- **Status** of the job
- **User** who triggered the job.
There is also a built-in filter to help you query the records you need.
### Filtering
The built-in filter allows you to narrow down jobs that fit your pre-defined set of criteria in the Job Monitoring Dashboard.
Job Status Filter
This filter will help you to find jobs by their statuses. To learn more about all job status available in Holistics, please refer to [Job Queue Management](/docs/jobs/queues-and-workers#life-cycle-of-a-job).
We also offer **two additional filters** for common job status:
- **All unfinished statuses**: which include **Starting**, **Pending**, **Running**.
- **All finished statuses**: which include **Success**, **Failure**, **Already Existed**, **Cancelling**, **Cancelled**.
Source Type Filter
This filter is useful to discover all jobs that have been fired by a particular type of resources.
## Monitoring job queues & workers
:::info Required Permissions
Available to **Admins** only.
:::
In the **Job Queues & Workers** tab, you will find a listing of your Job Workers, including:
* **Percentage**: Percentage of Job Workers that are busy
* I.e. The percentage of Busy Workers over Total Workers.
* **Busy Workers**: Number of Job Workers that are actively executing Jobs.
* **Total Workers**: Total number of Job Workers that are available in a certain Job Queue.
## Job performance analytics
:::info Available in Open Beta
Please check out our [announcement](https://community.holistics.io/t/closed-beta-job-performance-analytics-dashboard/1793).
:::
:::info Required Permissions
Currently available to **Admins** only.
:::
## Data retention period
Please refer to our [Data Retention Policy](/docs/security-compliance/data-retention) for more information.
---
## Performance monitoring
:::info Feature in development
This feature is in active development, and now in **Open Beta** for all Holistics customers.
Please follow our [Community post](https://community.holistics.io/t/open-beta-job-performance-analytics-dashboard/1793) for updates on this feature.
:::
## Introduction
At Holistics, we believe in giving our customers more visibility over their reports’ performance, to make necessary optimizations more easily.
Here are some use cases our **Performance Analytics** dashboard can help with:
- Find out the slowest Reports in a certain Dashboard of your Holistics workspace
- Find out the slowest Dashboards in your Holistics workspace
- Monitor and see whether your Holistics Job Workers are overloaded or not
- Monitor your Dashboards’ performance after making optimizations to your SQLs or Data Warehouse
## How to access
To access the Performance Analytics dashboard, click on the `...` symbol on the top right of your current dashboard, and click **Performance Analytics** in the expanded menu:
---
## Usage monitoring
:::caution Note
- Usage Monitoring dashboard is available to **all customers**
- However, certain extra features in Usage Monitoring will be available in **Standard Plan and above** only
- These extra features include: **Export of Widget data** (Excel and CSV)
- For customers who are eligible for these extra features but cannot find it in-app, or customers in Entry Plan and would like to give them a spin, please let us know via this form: Holistics's Support Ticket.
:::
## What is usage monitoring dashboard
Usage Monitoring dashboard displays multiple metrics which reflect internal usage and activities of your organization’s dashboards.
Only **Admins** are authorized to access this dashboard.
## How to access
To access Usage Monitoring, go to
menu Menu -> monitor Usage Monitoring.
## Metrics
The dashboard consists of various widgets that present different usage metrics, grouped into Dashboard Usage and User Activities categories.
### Dashboard usage
1. Total Views on dashboards
2. Total Dashboards count
3. Views of dashboard over time
4. Count of dashboard viewed over time
5. Active Dashboards listing: list of viewed dashboards, sorted by their (1st) last view date & (2nd) view count
6. Dashboards with no view: list of dashboards having no view within the filtered time range
### User activities
1. Total Users count by User role
2. Count of Viewers of Dashboards over time
3. Active Viewers listing: list of dashboard viewers, sorted by (1st) last view date & (2nd) view count
4. Users with no dashboard view activity: list of Users making no view within the filtered time range
## Interact with the dashboard
The Usage Monitoring dashboard offers several interactive features, which are useful when you want to explore the usage data further.
### Filters
- **Time range filter**: you can edit the value for your desired time range of data.
:::info
According to our [**Data Rentention Policy**](/docs/security-compliance/data-retention), Holistics will retain your data for **180 days** before removing it from the system. Therefore, there will be no usage data prior to this 180-day mark.
:::
- You can also use [**Cross-filtering**](/docs/cross-filtering) feature to interact with the data points.
### Date-drill
You can utilize the [Date-drill](/docs/interactions/date-drills) feature to quickly change the time granularity of Usage data as well.
## Export widget data
You can download the data (Excel, CSV) of Usage Monitoring dashboard & its widgets.
:::info Note
Exporting widget data is available in **Standard Plan and above**.
:::
## Acknowledgements
- Usage Monitoring contains the usage data of **dashboards**, particularly:
- Quick Dashboard (Dashboard 3.0)
- Canvas Dashboard
- Usage Monitoring counts both the **in-app views** and the **public views** (embedded and shareable links) of dashboards
- However, since public viewers of Shareable Links are anonymous, Holistics will accumulate all Shareable Link views count of that dashboard under one single user ID *(example: `public_abcde12345@holistics.io`)*
- For example: If 3 public users visited a shareable link 10 times in total, there will be only one *`public_abcde12345@holistics.io`* email responsible for the 10 views.
- The same goes for Embedded Analytics view count of a dashboard *(example: `embed_abcde12345@holistics.io`)*.
- For other objects such as **datasets** or **data models**, their usage is not included.
*If you have any requests of other usage metrics, please reach out to us via Usage Monitoring dashboard’s Feedback form or Support tickets.*
## Data retention period
Please refer to our [Data Retention Policy](/docs/security-compliance/data-retention) for more information.
---
## Moving Calculations
:::info Feature Prerequisites
- **4.0 Version**: If you are currently using version 3.0, you'll need to [upgrade to 4.0 version](/as-code/3.0-to-4.0-migration) to use this feature and other cool stuff.
- **AQL-Enabled**: Once your version is upgraded to 4.0, make sure you have [enabled AQL for your datasets](/as-code/aql/enabling-aql).
:::
:::tip Alternative
This is the built-in, UI-centric functionality to calculate the Moving Calculations. Alternatively, you can use AQL expressions with the window functions as detailed here: [AQL Window Functions](/reference/aql/window-function).
:::
The **Moving Calculation** feature is flexible and covers many common analytic use cases, such as:
- Drawing a moving average line to smooth out trends when there are wide fluctuations in your sales data;
- Calculating the cumulative total sales over time to keep track of how close you are to your sales target.
Moving Calculations are supported in various visualizations:
- Table
- Pivot table
- Line chart family including line, column, bar, area, and combination charts
- Metric sheet
You can learn more about how to use this feature in our tutorial video below:
VIDEO
---
## Open semantic layer
## Your metrics, everywhere
Traditional BI tools lock your metric definitions inside their platform. Define "revenue" in one tool, and you can't use that same logic in a Python script or feed it to an AI agent.
Holistics takes a different approach: **your semantic layer is open**. Once you define a metric in Holistics, you can query it from anywhere - notebooks, internal applications, data pipelines, or any system that makes HTTP requests. One definition, unlimited consumption.
## What makes it open
An "open" semantic layer isn't just about having an API. It's about giving you full ownership and control over your business logic through three pillars:
### Code-based definitions
Your metrics are defined in [AML (Analytics Modeling Language)](/reference/aml/) - human-readable code that lives in your repository, not hidden inside a proprietary database.
This means your semantic layer is:
- **Portable** - Move between environments or tools without losing your work
- **Reviewable** - Use code review workflows for metric changes
- **Transparent** - Anyone can read and understand how metrics are calculated
### Version controlled
Because your semantic layer is code, you get the full power of [Git version control](/docs/git-version-control):
- **History** - See who changed what and when
- **Branching** - Test metric changes in isolation before merging
- **Rollback** - Revert problematic changes instantly
- **Collaboration** - Multiple team members can work on different parts simultaneously
### Programmatically accessible
Query your metrics from anywhere via [API](/api/v2/query-data) and [CLI](/docs/cli):
- **API** - HTTP endpoints let any application fetch governed metrics
- **CLI** - Local development tools integrate with your existing workflows
- **CI/CD** - Validate metric definitions automatically before deployment
Together, these ensure you're never locked in. Your business logic stays yours.
## Why this matters
When your semantic layer is open, you get:
- **Single source of truth** - One metric definition serves dashboards, notebooks, internal apps, and AI agents
- **No vendor lock-in** - Your business logic is accessible via API, not trapped in a proprietary format
- **Governed flexibility** - Centralized definitions with decentralized access means consistency without bottlenecks
This means you can invest in building a rich semantic layer in Holistics without worrying about future flexibility. Your work stays accessible regardless of how your data stack evolves.
## What you can build
With programmatic access to your Holistics datasets, you can extend your metrics beyond dashboards:
| Use case | Description |
|----------|-------------|
| **Enrich analysis in notebooks** | Pull metrics into Jupyter, Python, or R for ad-hoc analysis that goes beyond what dashboards offer |
| **Power internal applications** | Serve trusted numbers to operational tools, admin panels, or customer portals |
| **Connect other BI tools** | Query from Metabase, Looker Studio, or any tool that can make HTTP requests |
| **Unit test metrics in CI/CD** | Validate metric definitions programmatically before deploying changes |
| **Enable AI agents** | Feed governed metrics to LLMs and AI assistants using the [MCP Server](/docs/ai/mcp-server) |
**[Get started with the API tutorial →](/api/v2/query-data)**
## Related capabilities
- **[MCP Server](/docs/ai/mcp-server)** - Let AI agents query your semantic layer directly
- **[Embedded Analytics](/embedded/)** - Embed full dashboards in your product
- **[Validation API](/docs/continuous-integration/validation-api)** - Validate AML changes in CI/CD pipelines
---
## Percent of Total(Docs)
:::info Feature Prerequisites
- **4.0 Version**: If you are currently using version 3.0, you'll need to [upgrade to 4.0 version](/as-code/3.0-to-4.0-migration) to use this feature and other cool stuff.
- **AQL-Enabled**: Once your version is upgraded to 4.0, make sure you have [enabled AQL for your datasets](/as-code/aql/enabling-aql).
:::
:::tip Alternative
This is the built-in, UI-centric functionality to calculate the Percent of Total. Alternatively, you can use AQL expressions with the of_all() function as detailed here: [Calculate Percent of Total using AQL](/as-code/aql/cookbook/aql-percent-of-total).
:::
Percent of Total allows you to see the distribution of a measure by specific dimensions, such as sales percentages by country or by product category.
Percent of Total is supported in various visualizations:
- Table
- Pivot table
- Line chart family including line, column, bar, area, and combination charts
- Metric sheet
For the best presentation of your analysis, we recommend using it in a **table** or a **pivot table**.
In this video, we walk you through how to create the Percent of Total in both a table and a pivot table, using **grand total**, **row total**, **column total**, and **custom total**:
VIDEO
---
## Canal Engine - high-performance connector and caching engine
**Canal** is our latest _Connector and Caching Engine_ that provides advanced performance features to your Holistics queries.
Its responsibility is to **connect to your Data Warehouses**, **trigger queries**, and then **efficiently retrieve the query results** from the Data Warehouses **into Holistics Cache** (aka. "Holistics Data Lake"), making the data readily available for further processing (e.g. rendering on browsers, exporting, etc.).
## How to enable canal
See [Enable Canal](/docs/connect/settings/enable-holistics-canal).
## Technologies
### Data streaming
Canal employs the data “streaming” technique that eliminates lots of overheads and bottlenecks when transferring data.
To be specific, it transfers the result data as small chunks from your Data Warehouse straight into Holistics Data Lake.
With data streaming, there is minimal-to-none overhead during the transfer, thus minimizing the latency between the time when the query is finished on the Data Warehouse and the time the result is visible to end-users.
### Connection pooling
Upon building Holistics Canal, we have taken the opportunity to implement Connection Pooling as well!
When a "canal" (or connection) has been constructed between Holistics and your Data Warehouse, Holistics will try to re-use that same connection for multiple queries.
This effectively cuts down the connection establishment costs (e.g. DNS lookup, Authentication, SSL, etc.) when running multiple queries on the same Data Warehouse, which typically reduces 100-1000ms of latency for every query.
### Golang
The whole Holistics Canal system (including Data Lake) runs on [Golang](https://go.dev/).
This gives many benefits to the system, including:
#### Faster execution
Old Holistics Connector runs on Ruby, which is an interpreted programming language. Compilations only happen during runtime (i.e. Just-in-time compilation). On the other hand, Golang compiles the code ahead of runtime, allowing the runtime to execute faster right from the get-go.
Golang also enables us to use more efficient data structures and make lower-level optimizations in our codes.
#### Better concurrency
Golang can spawn multiple Goroutines working in parallel.
In our illustration, Golang allows us to operate on multiple “currents” at the same time, right in the middle of the flow/streaming:
On the other hand, Golang concurrency also allows _sharing memory_ between parallel/concurrent executions, which has also facilitated the Connection Pooling feature mentioned above.
#### Access to better data processing tools and technologies
* Golang has first-class support from major Databases/Data Warehouses. Thus, the Golang database connector libraries are often readily available, more performant, have more features, and have fewer bugs.
* Apache Arrow and Apache Parquet libraries are also very well-maintained in Golang, while they are still pretty primitive in Ruby at this moment.
### Apache Arrow and Apache Parquet
Holistics Canal uses [Apache Arrow](https://arrow.apache.org/) as the data format for transferring data into the Data Lake.
* It avoids the cost of “unloading” and “loading” data (i.e. serialization and deserialization) into and from the Data Lake.
* For Data Warehouses (e.g. BigQuery and Snowflake) that use columnar storage themselves and provide Apache Arrow as query output format, this can also avoid the cost of “unloading” data from the Data Warehouses.
* It can be seamlessly stored and processed as a **columnar data storage**, enabling fast data analytics and retrieval.
* Currently, we store the Arrow data as [Apache Parquet](https://parquet.apache.org/) files, which provide storage compression and portability while still being fast enough when queried by Duckdb.
### DuckDB
We use [Duckdb](https://duckdb.org/) as our Cache Query Engine:
* **Features**: Duckdb provides lots of useful querying and analytics [features](https://duckdb.org/why_duckdb.html#feature-rich).
* **Speed**:
* Duckdb vectorized query execution model allows [high-performance querying](https://duckdb.org/why_duckdb.html#fast) on cached data.
* Duckdb can output Arrow data, which again is very efficient when transferring to post-processing services.
### Future
As the tools and technologies around Apache Arrow and Duckdb are evolving every day, we can expect to incorporate more features into Holistics and improve Holistics performance even further in the future!
## References
* For a more detailed comparison between Holistics Canal and our previous engine, please check out this [Community Post](https://community.holistics.io/t/upcoming-holistics-canal-fast-connector-and-caching-system/2132).
* To learn more about how Holistics Caching works, please refer to [Caching Mechanism](/docs/performance/data-caching).
---
## Caching mechanism (cache)
As users access reports (widgets), Holistics stores the data that's returned by queries in a cache. That way, if user interaction with the report results in a query that's already been issued, Holistics can try to read the data from the cache.
Query caching can optimize the performance of your dashboard when avoiding running the query against the database to get fresh query results. This can also reduce cost by reducing the overall number of queries.
## How Holistics cache data
Holistics works by connecting directly to your SQL database instead of storing your raw data on our own server. When you open or access a report with specific filter parameters, Holistics will send report SQL query to the database, wait for the results and visualize them to the end users.
1. Behind the scenes, once the query is sent to the database, the result of that query is cached.
The duration of this cache is configurable (min 10 minutes, and can be configured). The generated SQL query is the cache key.
In short, the cache is generated on-demand. It means when someone **first** visits the dashboard, each widget’s result will be cached separately.
2. When a new query is executed, the cache is checked to see if the query is similar to the previous run. All filters and row limits must be the same for the cached data to be used.
- *If the query has been executed before* and within cache duration, the results will be retrieved from the Holistics cache instead of sending a fresh query to your database.
- *Otherwise*, Holistics runs the query against the database to get fresh database results (and those results are then cached).
What this means is that the data sent to the subsequent users will only be as fresh as the time the first user access the data. Even so, the subsequent user can choose to force reload or change the filter parameters and Holistics will send a new query to your database.
> **Note that**: When a **filter** is applied to the dashboard, a **new query** will be generated (based on the **WHERE** clause).
>
## Cache duration
You can specify **Caching Duration** to control how long your result sets are stored in Holistics's cache server before expiring. After the specified time period, the cache will expire and is cleaned from Holistics's cache server.
In reports/dashboards, you can configure Cache Duration in the Cache Settings UI, which can be accessed from the Refresh button or Report/Dashboard Preferences.
Adjusting this parameter means balancing your needs for **performance** (or **cost**) and **data freshness:**
- **Short cache duration** gives you fresher data. But the cache expires faster. Users may occasionally suffer a long loading time if the cache already expired. This may also increase the cost (Query-run consumption) due to more queries will be sent to the database.
- **Long cache duration** yields shorter report loading time, but might give **stale data**.
:::info NOTES
- Caching cannot be disabled
- The minimum interval is 10 minutes and cannot be set lower
- To build real-time dashboards, see this [section below](#real-time-dashboards)
- Updating the Cache Duration only affects **future** cache entries (see [FAQ](#i-have-updated-the-cache-duration-but-it-isnt-effective))
:::
## Walkthrough an example
Say that you have dashboard A with 8 charts (widgets) and no cached data. The cache duration is set to be 24 hours.
- The first time you open the dashboard (with no cache), all widgets will be run for the first time. That means:
- 8 queries will be sent to your SQL database for processing.
- Each widget's result will also be cached (for 24 hours).
- If you refresh the dashboard right after: Since the data is already in the cache, no new queries will be sent to your database.
- Now, if John visits the **dashboard** and modifies **with filter A** (for i.e: filter "Country = Vietnam”) at 9:00 am.
- Since there are no existing caches for the new queries, the new queries will be sent to your database for processing
- These new result sets will also be cached by Holistics (for 24 hours).
- The cache would expire at 9:00 am the next day.
- During this time, anyone who visits this **dashboard A and uses the same filter A** will see the cached data, no new queries will be sent to your database.
- Next, Amy visits **dashboard A with filter C** at 10:00 am.
- This doesn't use the cached data, because she uses a different filter value.
- Now, a query is triggered, the result is cached from 10 am -> 10 am the next day
To summarize, the cached data is generated on-demand when the 1st user visits the dashboard, then the countdown starts from this moment.
Vice versa, if no one visits the dashboard, then there's no cached data for that dashboard.
## Forcing reload from the database
In reports/dashboards, when clicking on the Refresh button, end-users will see the time when that query was last updated (the time when the current result was cached). Clicking on the `Refresh Now` will manually rerun the query and override the cached result with the new result.
## Clear all cache of a database
You can use [Data Sources' Bust Exploration Cache API](/api/v2/reference/data-sources-bust-exploration-cache) to clear all cache of a data source.
## Real-time dashboards
To build dashboards that query fresh data every time end-users visit, add a `now` time filter to the dashboard visualizations:
When using `now` syntax, each query is still cached. But because the query is different every time, the cache isn't shared between different visits. [Learn more](/docs/datetimes/relative-dates#caching-implications)
:::info Note
If you add the time filter as a dashboard filter, make sure it is [applied](/docs/filters/date-filters#mapping-a-date-filter) to all visualizations.
:::
## FAQs
### Is the cache invalidated when i change filter values?
**Question:** I notice that data caching is not very useful when you have filters in the widget/report. As soon as you change filter value query is running over and you have to wait again. What can we do here?
**Answer:**
When the filter value changes, the underlying query changed thus invalidated the cache.
What we can suggest is that you can use our [Query Model](/docs/query-models) functionality to build pre-aggregated tables, so that the queries will only query a small aggregated result set instead of scanning the entire raw tables.
### How long is the list of filter options cached?
Holistics fetches up to 100k distinct values for a [field filter](/docs/filters/field-filters#holistics-will-fetch-100k-distinct-values-of-the-filter-and-cache-them-for-8-hours) and caches that dropdown list for 8 hours. After 8 hours, the list of selectable options is refreshed the next time the filter loads. (Changing the selected filter value still triggers a new query, as described above.)
### How does caching work with relative date syntax?
Please refer to this [doc](/docs/datetimes/relative-dates#caching-implications).
### Is the cache invalidated when i explore/edit the widget?
**Question:** I view Dashboard Widget A (the query gets sent to cache). After I click on Explore/Edit on Widget A, will this first run of the initial widget load be fetched from cache?
**Answer**: When exploring/editing, we are applying an upper limit of 100k rows. The limit is meant to make the preview faster. But it results in different queries when in view mode (no limit) and edit/explore mode (100k).
### What happens if my source table changes everyday, but my cache duration is set to 2 days?
If your dashboard is in cache duration, and your dashboard viewers **do not create modifications that cause new queries to be generated** (like changing a filter, or adding new fields into the report, etc.), Holistics will use the cached query result instead of sending a fresh query to your database.
### I have updated the cache duration but it isn't effective
Updating the Cache Duration only affects **future** cache entries. It does not modify or invalidate existing cache entries.
You can either _wait_ until the existing cache entries expire, or _forcefully clear_ them by [refreshing individual dashboards](#forcing-reload-from-the-database) or [clearing cache of a whole data source](#clear-all-cache-of-a-database).
---
## Performance
Holistics report performance is bound by the slowest of these stages:
1. **Pre-query processing**: parsing, permission checks, query plan
2. **Job queuing**: waiting for an available worker
3. **Query execution**: running on your data warehouse
4. **Result transfer**: moving the result set from your data warehouse to Holistics
5. **Post-query processing**: caching and result assembly
6. **Browser render**: drawing the visualization
For the full breakdown, see [Report Running Mechanism](/docs/performance/report-running-mechanism).
## Common levers
A few high-leverage ways to make things faster, before you go deep into diagnosis.
Tune the underlying datasets and models to speed up SQL execution, usually the biggest bottleneck.
Fewer rows means faster transfer and rendering. Apply filters, limits, and group small series into "Other".
Each widget spawns its own job. Aim for 10 to 15 per dashboard and use drill-through for extra detail.
Tune cache duration to the freshness and cost trade-off your dashboards need, and persist slow, stable queries.
For frequent slices over large fact tables, route queries to pre-aggregated tables transparently.
Speed up result transfer when results are large or your warehouse sits far from a Holistics region.
Queries run on your warehouse, so its processing power and configuration set the ceiling.
## Diagnosing problems
- **Symptom-based diagnosis**: [Performance Troubleshooting](/docs/performance/troubleshooting) walks through what each report-loading status means and how to fix it.
- **Slow jobs you can't pin down**: [Report Slow-Running Jobs](/docs/jobs/report-slow-jobs): how to file an actionable support request.
If none of this matches what you're seeing, contact [support@holistics.io](mailto:support@holistics.io) with the URL, the slow status, and a screenshot/recording.
## References
- [Report Running Mechanism](/docs/performance/report-running-mechanism)
- [Data Caching](/docs/performance/data-caching)
- [Canal Engine](/docs/performance/canal-engine)
- [Job Queues and Workers](/docs/jobs/queues-and-workers)
- [Job Controls](/docs/jobs/controls)
- [Aggregate Awareness](/docs/aggregate-awareness)
- [Status Page](https://status.holistics.io/)
---
## Report running mechanism
## What happens when you open a report?
When a user opens a report (a chart) in Holistics, it will typically go through these steps:
1. **Pre-Query Processing:** Pre-query processing tasks like parsing the modeling layer, applying permission rules, building the query plan, etc.
2. **Job Queuing:** A new job is pushed into the Job Queue, waiting for the next available worker.
3. **Query Execution:** SQL query is executed against the customer’s Data Warehouse.
4. **Transfer the result set to Holistics server:** Once the query finishes, the result set is transferred to Holistics servers.
5. **Post-Query Processing:** Once the query is finished, the result set then goes through additional processing, mainly consisting of caching operations.
6. **Transfer the result set to user’s browser:** The result set (or a part of it) is transferred to the user’s browser.
7. **Visualize the result set on user's browser:** The visualizations are rendered from the result set on the user's browser.
:::tip Note
If the query result is [cached](/docs/performance/data-caching#cache-duration), step 2 to 5 of this flow **would not be executed**.
:::
Understanding the above process will help us pinpoint the bottlenecks and factors that cause performance problems with your reports. It is important to determine which steps are causing the problem, then troubleshoot the underlying issues.
## Can Holistics work with large volumes of data?
To answer this question, let's look at this _simplified_ flow of a **single Report execution**:
* Holistics does not have any constraint on the amount of data on your Data Warehouse.
* Your Data Warehouse can have billions of rows or more, per its own capacity and capabilities.
* **Thus, Holistics can technically work with any amount of data on the Data Warehouse.**
* In the SQL execution step:
* **Holistics only fetches 1 million rows (at maximum) from the SQL _result_.**
* To learn more about this limit, see [Row limits](/docs/admin/row-limits).
* To reduce the default limit to better control report performance, go to Admin Settings -> Visualization -> Default Records Limit
* The speed of this step depends mostly on the speed of your Data Warehouse.
* When fetching data from Holistics Worker to the browser:
* For table-like visualizations (e.g. Data Table, Pivot Table):
* Holistics automatically apply pagination where applicable, so that it efficiently fetches and renders necessary data only.
* For non-table visualizations (e.g. Column Chart, Pie Chart):
* It can get slower when there are more data points to visualize (e.g. > 10K rows/data points), as it negatively affects the browser performance.
* In general, having fewer data points in charts will make them faster and more usable/readable to end-users.
---
## Performance troubleshooting
When a report is slow, identify which loading status is taking the time, then jump to the matching symptom below. For warehouse-side investigation (slow query execution, high CPU, EXPLAIN ANALYZE), see [Diagnose data warehouse issues](#diagnose-data-warehouse-issues) at the bottom.
## "My report takes too long to load"
Try to narrow down to one of the more specific symptoms below (Pending, Running query, Processing result, Loading, or Fetching result).
## "My report takes too long at **Pending** status"
### Causes
- Your Report [job queue](/docs/jobs/queues-and-workers) is overloaded
- And/or this user has reached their [Per-user Report Job Limit](/docs/jobs/controls#limit-number-of-report-jobs-run-per-user)
### Resolution
1. Validate the cause using [Blocked Jobs Analysis](https://community.holistics.io/t/closed-beta-job-performance-analytics-dashboard/1793/3) of Job Performance Analytics
2. Possible optimizations:
- Optimize slow jobs (see "Running query…" below) to free up Report workers
- [Disable Dashboard auto-run](/docs/dashboards/settings#dashboard-auto-run)
- Increase the [Per-user Report Job Limit](/docs/jobs/controls#limit-number-of-report-jobs-run-per-user)
- Request to [increase your Report Workers](/docs/jobs/controls#increase-your-default-slots-for-specific-job-queues)
- Adjust [Unused Job Timeout](/docs/jobs/controls#automatically-cancel-unused-jobs)
## "My report takes too long at **Running query…** status"
### Causes
#### Without Holistics Canal
- The query execution is taking long on your Data Warehouse → see ["My query takes too long to run on the data warehouse"](#my-query-takes-too-long-to-run-on-the-data-warehouse) below
- And/or the query result is too big and slow to fetch back to Holistics. Try:
- [Migrating to a Holistics Data Center](/docs/security-compliance/data-centers#migrating-to-another-region) physically closer to your Data Warehouse
- [Reducing data points](/docs/performance#reduce-the-number-of-data-points-on-a-report)
- [Enabling Holistics Canal](/docs/connect/settings/enable-holistics-canal)
#### With Holistics Canal
- The query execution is taking long on your Data Warehouse → see ["My query takes too long to run on the data warehouse"](#my-query-takes-too-long-to-run-on-the-data-warehouse) below
### Notes
- How to validate the cause:
- Compare Holistics Job Logs against your Data Warehouse logs (Holistics Job Logs are in [Job Monitoring](/docs/monitoring/job-monitoring#monitoring-jobs))
- Compare against the execution/query plan on the Data Warehouse (e.g. using `EXPLAIN ANALYZE`): see [Run EXPLAIN ANALYZE on slow/suspected queries](#run-explain-analyze-on-slowsuspected-queries)
- Try downloading the data from the Data Warehouse console and time the download
## "My query takes too long to run on the data warehouse"
### Causes
- The query is costly/complex and slow by itself
- Check the execution/query plan on the Data Warehouse (e.g. `EXPLAIN ANALYZE`): see [Run EXPLAIN ANALYZE on slow/suspected queries](#run-explain-analyze-on-slowsuspected-queries)
- Possible optimizations:
- Pre-aggregate data, manually or with [Aggregate Awareness](/docs/aggregate-awareness/)
- Use [Query Parameters](/docs/query-parameters) to push down predicates and use database indexing more efficiently
- Persist your query with [Model Persistence](/docs/query-models#model-persistence) (or database materialized views, dbt persistence, etc.)
- Add indexes/clustering/partitioning on the Data Warehouse
- The Data Warehouse is overloaded
- Increase resources (CPU, memory) on the Data Warehouse
For deeper warehouse-side diagnosis, see [Diagnose data warehouse issues](#diagnose-data-warehouse-issues) below.
## "My report takes too long at **Processing result** status"
### Causes
- The query result is too big: slow to cache or render in Holistics
### Resolution
- [Reduce data points](/docs/performance#reduce-the-number-of-data-points-on-a-report)
- [Enable Holistics Canal](/docs/connect/settings/enable-holistics-canal)
## "My report takes too long at **Loading** status"
Possible causes:
- Holistics is taking long on report pre-processing: usually because your modeling code is large or complex
- The browser is slow to communicate with Holistics servers, typically because:
- Your internet connection
- You're physically distant from Holistics Data Center → consider [migrating to a closer Data Center](/docs/security-compliance/data-centers#migrating-to-another-region)
- And/or your modeling code is large
We continuously improve the Loading step. If it's still slow, contact support@holistics.io with:
- The URL of the page
- The Report or Dashboard title
- The duration of the Loading status
## "My report takes too long at **Fetching result…** status"
:::info Note
This status only appears when [Holistics Canal is enabled](/docs/connect/settings/enable-holistics-canal).
:::
### Causes
- The query result is too big and slow to fetch back to Holistics
### Resolution
- [Migrate to a Holistics Data Center](/docs/security-compliance/data-centers#migrating-to-another-region) physically closer to your Data Warehouse
- [Reduce data points](/docs/performance#reduce-the-number-of-data-points-on-a-report)
## "My users complain about performance but I don't know where to start"
Use [Job Performance Analytics](/docs/monitoring/job-monitoring#job-performance-analytics).
## Other / unmatched
If your performance issue doesn't match any symptom above, or the suggested solutions don't work, contact Holistics Support with as much of the following as possible:
- The URL of the page
- The duration you experienced and the duration you expected
- Screen recordings or screenshots
- The steps that led to the issue
- If a job is involved, follow [Report slow-running jobs](/docs/jobs/report-slow-jobs)
---
## Diagnose data warehouse issues
This section covers warehouse-side investigation methods: long-running jobs, repeated failures, CPU usage, and query plans. Use it together with the symptom-based diagnosis above when the issue points back to your data warehouse.
### Investigation methods
#### Identify long-running jobs in Job Monitoring
See [Job Monitoring](/docs/monitoring/job-monitoring).
#### Check if there are many failed jobs with the same errors
If multiple jobs fail in a short window, the cause is often systemic, usually in the data warehouse layer. Common patterns:
- DB fails to write Persisted Models
- DB out of memory
- DB connection refused
- DB connection timed out
To investigate:
1. Open [Job Monitoring Dashboard](/docs/monitoring/job-monitoring).
2. Look for many jobs failing with the same error. The screenshots below show jobs failing because of issues on the data warehouse side.
#### Check if CPU utilization of your data warehouse is high
:::info
Not every data warehouse offers CPU utilization monitoring. If yours doesn't, look up workarounds for your specific warehouse.
:::
If CPU utilization is high, SQL queries take a long time to process. Commands like [cancelling a job](/docs/job-queue-optimization#cancel-running-jobs) won't even run because the database is unresponsive.
Common causes of high CPU utilization:
- High number of logical reads, due to:
- Queries that lack indexes
- Outdated index statistics
- Inefficient queries
- Increased workload
To investigate:
1. Check the CPU utilization metric on your warehouse:
- [Use AWS CloudWatch to monitor CPU Utilization of AWS RDS](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/monitoring-cloudwatch.html)
- [Monitoring Warehouse Load in Snowflake](https://docs.snowflake.com/en/user-guide/warehouses-load-monitoring.html)
2. If CPU is frequently abnormally high, list the long-running queries during peak periods:
- [Detect heavy processes in AWS RDS](https://aws.amazon.com/premiumsupport/knowledge-center/rds-instance-high-cpu/#:~:text=Using%20queries%20to%20detect%20the%20cause%20of%20CPU%20utilization%20in%20the%20workload)
- [Use PostgreSQL pg_stat_activity to monitor long-running queries](https://aws.amazon.com/premiumsupport/knowledge-center/rds-instance-high-cpu/#:~:text=Using%20queries%20to%20detect%20the%20cause%20of%20CPU%20utilization%20in%20the%20workload)
- [Identify spikes in Snowflake and use History to list out the heavy queries](https://docs.snowflake.com/en/user-guide/warehouses-load-monitoring.html#peak-query-performance)
3. Send these queries to Holistics Support.
#### Run EXPLAIN ANALYZE on slow/suspected queries {#run-explain-analyze-on-slowsuspected-queries}
`EXPLAIN ANALYZE` reveals the query plan, which is useful for identifying which parts of a slow query take the most time.
##### General instructions
1. **In Holistics, get the generated SQL of your slow job.**
Go to [Job Monitoring](/docs/monitoring/job-monitoring) and find the slow job's logs. Copy the generated SQL. Holistics-generated SQL has a `/* - Job ID: xxxxxxxxxx* */` comment at the top.
2. **Append `EXPLAIN ANALYZE` and run it against your data warehouse.**
Paste the SQL into your DB engine, prepend `EXPLAIN ANALYZE`, and run. Send the output to Holistics if you're not sure how to interpret it.
To visualize the query plan, paste it into a tool like [explain.depesz.com](https://explain.depesz.com).
##### Snowflake-specific instructions
1. Same as step 1 of General Instructions.
2. Find the full query in the Snowflake UI:
- Search the [History page](https://docs.snowflake.com/en/user-guide/ui-history.html) using SQL text parameters
- Or re-run the SQL in Holistics Editor
Then go to Snowflake's History page and look for the latest query.
3. Get detailed query info.
:::info
When sending troubleshooting info to Holistics, include the **Query Detail** view and **all Query Profile screenshots**.
:::
Click the SQL to open the **Query Detail** view.
Navigate to the **Query Profile** tab (there can be multiple profile tabs, check all of them).
##### References
- [PostgreSQL](https://www.postgresql.org/docs/current/sql-explain.html)
- [BigQuery](https://cloud.google.com/bigquery/docs/query-plan-explanation)
- [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/explain)
- [Redshift](https://docs.aws.amazon.com/redshift/latest/dg/r_EXPLAIN.html)
- [Athena](https://docs.aws.amazon.com/athena/latest/ug/athena-explain-statement.html)
### Mitigation methods
#### Cancel long-running jobs in Holistics
See [Cancel long-running jobs](/docs/job-queue-optimization#cancel-running-jobs).
#### Manually cancel jobs in your data warehouse
For jobs that can't be [cancelled via Job Monitoring](/docs/job-queue-optimization#cancel-running-jobs) (e.g. field-suggestion jobs), cancel them from the warehouse directly.
1. Get the pid of the long-running process. Check the **query** column to confirm it's the right one:
```sql
select * from pg_stat_activity
where query_start < now() - interval '1 hour'
```
2. Cancel the long-running process:
```sql
select pg_cancel_backend(the_pid_collected_from_step_1)
```
### Improvement methods {#improvement-methods}
#### Add table indexing/partitioning/clustering keys where relevant
Indexes can dramatically improve query speed and reduce CPU usage on your warehouse.
1. Add indexes where the [query plan](#run-explain-analyze-on-slowsuspected-queries) suggests they help:
- [Add indexes in PostgreSQL](https://www.postgresql.org/docs/current/sql-createindex.html)
- [Add clustering keys for Snowflake](https://docs.snowflake.com/en/user-guide/tables-clustering-keys.html#defining-a-clustering-key-for-a-table)
2. Run `EXPLAIN ANALYZE` again to confirm the indexes are used.
Adding indexes speeds up reads but slows inserts and uses extra disk. Consider trade-offs (see [this overview](https://www.scaler.com/topics/sql/advantages-and-disadvantages-of-indexing-in-sql/#disadvantages-of-indexes-in-sql)).
#### Increase data warehouse computing resources
Each data warehouse has its own scaling tools. A few examples:
- **Redshift**:
- [Resizing clusters](https://docs.aws.amazon.com/redshift/latest/mgmt/managing-cluster-operations.html#rs-resize-tutorial)
- [Concurrency scaling](https://docs.aws.amazon.com/redshift/latest/mgmt/managing-cluster-operations.html#rs-resize-tutorial)
#### Optimize your reports
See [Common levers](/docs/performance#common-levers) on the Performance overview.
---
## Product Philosophy
This is the foundation that guides our vision, roadmap, and product design. As you evaluate Holistics, it may be useful to understand the philosophy and principles we subscribe to.
These principles aren't decorative. They're the reason Holistics is built around an [expressive semantic layer](/as-code/aql/) and [analytics-as-code infrastructure](/docs/analytics-as-code/), and the reason [Holistics AI](/docs/ai) is structurally different from text-to-SQL tools. Each principle below ladders up to that.
## Death to silos
Barriers between teams and tools are the culprit of many issues in modern data analytics processes. One of the key focuses of Holistics products and methodologies is to break down barriers between teams, data processing, and storage tools, facilitating the flow of data inside an organization. We believe this flow is essential for an effective data-driven organization.
By committing to a codification-focused and API-first approach to product design, we ensure that any part of Holistics tooling and process is not locked and can be easily integrated with a data team's existing processes and tools.
## Declarative, not imperative
There are two schools of thought when it comes to data processing: declarative processing and imperative processing. The declarative approach emphasizes the end result and lets the tools handle the steps to get to that end result. On the other hand, the imperative approach lets end users specifying the specific steps to get to a certain end result.
We strongly prefer the former as we believe it allows users to only concern with what they want, instead of how they want. How data processing is done depends on the technology of the day. On the other hand, what the end result represents is timeless. Once abstracted away from technical specific detail, it will allow data processing to be more robust and durable.
## Short feedback loop
Long feedback loops allow defects in data processing to propagate and cause untold damages in the end results, eroding the trust of data teams. At Holistics, we believe that all data processes should have short feedback loops, which allow for early detection of potential issues and preventing defects to propagate downstream. Consequently, it increases the robustness of the process, and ultimately the trust of the data team.
## Reliability through codification
Tracking changes and dependencies for data processing is essential for ensuring the data process to be robust and reproducible. Encoding the whole process in a textual language allows changes and dependencies to be stored in a powerful version control system that tracks what changed and who changed.
As a result, data teams have the peace of mind that business-critical data processes are safe and reproducible. Any change that causes failure can be safely reverted to a previous, known good state easily. Explicit dependencies allow for easy debugging when failures occur.
The reliability of the whole process improves as a consequence.
## Automation through codification
Encoding the whole process in a textual language also work as the building blocks for further automation to be built on top. Automation allows for more reliable, repeatable processes and saves manual effort involved in data processing.
## Experimentation through codification
Experimentation is the key to allow an organization to get more out of data analytics. Encoding data processing in a textual language stored in a powerful version control system empowers data team members to create forks or branches of the data processing logic. This allows them to do their own experiments without affecting the rest of the team. Less friction to experiment means more experiments that generate values for the organization.
## Centralized definition, decentralized access
There has always been a tension between ensuring data processes are robust and reliable, and empowering all members of an organization to have access to data. Centralizing definitions with codification and allowing all members to build data products on top of that base is a method that results in the best-of-both-world outcome for the organization. At Holistics, we strive to achieve this ideal in all of our products.
## Thoughts-augumenting tools
At Holistics, we believe tools should not only allow their users to achieve a certain outcome, but also should elevate their users' cognitive abilities, assisting them on the path to mastery of their own careers. We strive to do this by providing new perspectives and new powerful ways to approach existing workflows and processes.
## AI amplifies the foundation
AI is only as trustworthy as the foundation it reasons from. Most AI analytics tools translate natural language directly to SQL against raw schema, and the output reflects that: plausible-looking answers that analysts still have to verify, with follow-up questions that fall apart on the second click.
The principles above (declarative definitions, codified business logic, short feedback loops, centralized definition with decentralized access) exist because they make the foundation strong enough that AI can reason from it correctly. When metrics are composable code objects (not SQL strings), AI can compose them. When business logic is versioned and reviewed (not mutable), AI's foundation stays consistent. When the semantic layer is expressive enough to handle real follow-up questions, AI doesn't fall off a cliff after the first answer.
This is why we believe the right way to build trustworthy AI analytics is to first build the foundation that makes it possible. Holistics invests in the semantic layer and analytics-as-code infrastructure precisely because **AI amplifies whatever foundation it stands on**, and a weak foundation produces unreliable AI no matter how capable the model is.
---
## 3.0 vs 4.0: Feature Comparison
## What is Holistics 4.0 (As-Code)?
**Holistics As-Code** (or Holistics 4.0) is the latest update of Holistics. The key innovation in this update is the **Analytics Modeling Language** (AML) that allow your entire analytics setting to be serialized into code, and can be version-controlled.
Key benefits of Holistics 4.0:
1. **Analytics As Code**: A full fledged [development](/docs/quickstart) experience enabling smoother experience for data teams. Dashboards, Models, and Data Definitions can now be better governed through a CI/CD pipeline.
2. **AI-Powered Analytics:** Holistics 4.0 provides AI capabilities such as natural language querying, AI-assisted data exploration, and AI functions for text analysis and data enrichment.
3. **Enhanced Data Modeling:** Our revamped data modeling capabilities allow for more intuitive model building with support for complex metrics definitions across models and easier management of data logic.
4. **Report Validation:** Keep your dashboards and reports up to date and safe from breakages with real-time check syncs, ensuring a change in the modelling environment does not break a report without your knowledge.
5. **Improved Performance:** We have made substantial improvements under the hood, resulting in faster query times and more efficient resource usage, even with large datasets. You also have greater control on your dashboard performance with our [Aggregate Awareness](/tutorials/using-aggregate-awareness) feature.
6. **Customizable Dashboards:** Version 4.0 introduces the powerful [Canvas Dashboard](/docs/dashboards/), allowing you to extensively tailor your visualizations to meet specific business needs or brand images.
7. **Expanded Analytics Functionalities:** We have expanded our analytics features with [AML Extend](/reference/aml/extend) (improves reusability and extensibility of definitions), [Dashboard Actions](/docs/actions), [Analytic Query Language](/as-code/aql/) (define complex metrics in one line of code)... and more.
## Feature Comparisons
Since there are major architectural change in 4.0, not all features in 3.0 have been ported over to 4.0 yet. This table provides a brief overview of the key differences/gaps between them.
Holistics Version
4.0 (as-code)
3.0
⭐️ Analytics As-Code:
Define models and reports using code
✅
❌
⭐️ Holistics AI:
Natural language querying, AI-assisted data exploration, and AI functions for text analysis
✅ Learn more
❌
⭐️ Canvas Dashboard:
Our canvas-based dashboards that can be configured as code.
✅ Learn more
❌
⭐️ Git Version Control:
Check code into Git. Develop in branches & create pull requests.
✅ Learn more
❌
⭐️ Integration with dbt:
Sync metadata from dbt to Holistics
✅ Learn more
❌
⭐️ AMQL: Our metrics-centric modeling/query language.
✅ Learn more
❌
⭐️ Query Parameters:
Support passing user inputs into model's SQL definitions at run-time.
✅ Learn more
❌
⭐️ Aggregate Awareness:
Optimize the query time by automatically using the right pre-aggregates
✅ Learn more
❌
Data Imports: Load data into SQL database
✅ CSV Import; 🚧 Google Sheets Import *(upcoming)*
✅
Modeling Experience
Modeling Workspace
Development workspace
Automatic Relationship Creation
✅
✅ See Automatic Relationship Creation from Database
Transform Persistence
✅ ⭐️ Support flow-based
✅ Time-based only
Model Dependency: Allow users to delete a model (or dataset) even if it has dependencies
✅ Learn more
❌
User Experience (UX)
Auto-update modeling fields after changing the SQL definition
✅
✅
UI to edit and run SQL model
✅ See release notes here.
✅
UI to edit and run Persistence
🚧 On roadmap.
✅
## Notes
### Holistics 4.0 does not have built-in Extract & Load features
Holistics 4.0 supports data write-back use cases, such as [Import CSV](/docs/import-csv#introduction).
However, for proper Extract & Load (EL) needs, we recommend using dedicated EL tools like Fivetran, Airbyte and StitchData, etc. instead.
### Relationship in Holistics 4.0
The [relationship](/docs/relationships) concept in Holistics works slightly different between 3.0 and 4.0.
For both versions, relationship is used to specify how data models are joined together. However, in **Holistics 3.0**, relationship is **dependent** on the data models. It cannot exist without predefined data models.
In **Holistics 4.0**, relationship definition is **decoupled** from data model. This means Relationship definitions can be placed inside data model files, or dataset files or in a separate `relationships.aml` file:
```json
// Approach 1: Relationship defined within the Data Model file
// File ecommerce.model.aml
model my_model {
...
}
relationship my_relationship {
...
}
// ---------------------
// Approach 2: Relationship defined in a Dataset file
// File ecommerce.dataset.aml
dataset abc {
models: [model_1, model_2]
relationships: [ relationship_config ]
}
// ---------------------
// Approach 3: Relationship defined in a separated file
// File relationships.aml
relationship aa {
...
}
```
When you delete your data models, the relationships between them are not deleted unless you explicitly do so.
---
## What's my current version?
:::info Note
If you signed up from March 2024 onwards, you are by default on our latest version 4.0.
[Product versions](/docs/product-versions/overview.md).
:::
Click the
help **Help** icon at the top right corner. The version would be displayed on the last line of the panel.
---
## Product Versions
This document aims to give an overview of all active product versions in Holistics.
**👉 [Check your current version](/docs/product-versions/check-version)**
## Main Versions
There are three main Holistics versions:
- **Version 2.0 (SQL Reporting)**: This version involves building reports directly by writing SQL. This version is no longer being actively developed.
- **Version 3.0 (Modelling-based Reporting)**: The key differences in this version are our modeling-centric layer, self-service analytics, modelling/datasets-based reports. This version is no longer being actively developed.
- **Version 4.0 (3.0 + Analytics-as-code)**: Our latest, actively developing version. Its key concepts include Analytics-as-code (with AMQL development, Holistics's Modeling & Querying languages), Holistics AI, Git Version Control, and dbt Integration.
Both version 2.0 and 3.0 are currently in maintainence mode (no new feature development). If you're on these 2 versions, consider [migrating to the latest version](/as-code/3.0-to-4.0-migration.md).
To differentiate these three main versions in details, please take a glance at the following table:
Concept
Description
Version 2.0
Version 3.0
Version 4.0
Development Status
🟠 Maintain
🟠 Maintain
✅ Active
Data Modeling Layer
A real-time layer above the database that centralizes all data components and logics of an organization
❌
✅
✅
Data Importing
The process of transferring data from other source into SQL database
✅
✅
✅ (from CSV, Google Sheets)
Data Transformation
The process of centralizing and optimizing SQL logic by pre-transforming data
✅
✅
✅
Self-service Analytics
The process of analyzing and examining data sets to discover patterns, relationships, and other insights
❌
✅
✅
Reporting
The process of preparing and presenting reports, allowing users to share actionable insights and make informed decisions
Build reports by writing SQL queries
Build reports by drag-and-drop
Build reports by drag-and-drop & natural language querying (AI)
Holistics AI
AI capabilities that support analytics workflows, including natural language querying for report creation.
❌
❌
✅
Analytics As-Code
The ability to do analytics work using Holistics's code language (AMQL)
❌
❌
✅
Git Version Control
Utilizes Git for code management, allowing users to track changes, create branches, and review code for quality and collaboration.
❌
❌
✅
dbt Integration
Connects dbt transformations with BI tools, improving metadata consistency and reporting accuracy
❌
❌
✅
User Roles
Learn more about User Roles in Holistics
Admin, Analyst, Viewer
Added: Explorer
Added: Explorer
## Transitional Versions (Holistics 2.5 and 2.7)
In the past, Holistics developed the two transitional versions to provide a stable release for 2.0 customers who were not yet ready to migrate to version 3.0.
- **Version 2.5**: A combination of 2.0 and 3.0 where you can both keep the old way to create reports from SQL and experience our Data Modeling Layer.
- **Version 2.7:** A more stable and upgraded version than Holistics 2.5, where 2.0 and 3.0 features are used and managed separately. Hence, it provides 2.0 customers a clearer migration path to 3.0 version.
Both of our transitional versions are **no longer active** and **not stable long-term**. If you're on one of these plans, to request an upgrade to **Holistics 3.0**, please [fill in this form](https://form.jotform.com/210551703814448).
---
## Analyze Data with SQL Editor
:::info
SQL Editor is only available to **analysts** role.
:::
Beside the [Dataset](/docs/datasets), Holistics also has the **SQL Editor** as another way to explore your data. This tool is suitable for running simple SQL queries to do ad-hoc analysis, to understand the shape of your data, or to prototype a data model setup.
Similar to the Dataset UI, it also has the visualization panel in case you want to visualize your query results:
The editor can be accessed from the top Navigation Bar:
### Running non-select SQL queries
By default, Holistics only allows SELECT statements to run against your database. However, you can still run non-SELECT statements (like INSERT, UPDATE, DROP, CREATE, GRANT...) in the SQL Editor by toggling on **Non-select query** option:
If this is not toggled on, the non-SELECT query will be invalidated by Holistics.
This functionality is available to all account types that has access to Query Editor (i.e Admin & Analyst).
:::caution
Non-select statements may affect your database directly and cause irreversible changes. Please take great care when using this functionality.
:::
---
## Query Model
:::info
If you are familiar with dimension modeling tools like [dbt](https://www.getdbt.com/) or [Dataform](https://cloud.google.com/dataform), Holistics' query models serve similar purpose to those tools, providing a light-weight way to do dimensional modeling.
For more details on when to model in dbt vs Holistics, please refer to [this document](/docs/dbt-integration/when-to-model-in-dbt-vs-holistics). We also support [dbt integration](/docs/dbt-integration/) that allows you to use both dbt and Holistics together.
:::
## Introduction
When you have duplicate SQL logic in multiple reports, or a slow-running complex SQL query you want to speed up performance, one possible solution is to pre-transform the data and centralize the logic. Query Model is designed to help with that use case.
In Holistics, a **Query Model** (or SQL Model) is a data model created from a SQL `select` statements that perform data transformations on other tables/models. Think of query model as a **view** (or **materialized view**) in your database.
**Benefits:**
- **Query performance:** Pre-aggregate large query that scan many rows into smaller table for performance improvement.
- **Reusability of logic:** Building reusable models to be used in multiple places, avoid repeating the same SQL query/logic.
- **Maintainability of logic:** Breaking down complex logic to multiple query models make it easier to understand and modify the query/logic.
- **Custom join logic**: Allowing custom join logic by creating a query model that joins multiple models together.
- **Query parameters**: Allows the model's logic to by dynamic based on user inputs.
:::tip
- **Store query result in the database:** By default the query result is calculated at run time and is not stored in your database. To store query result, you will need to use [**Model Persistence**](#model-persistence).
- Holistics's Query Model is best used for **straight-forward, small scale transformation**. For more advanced transformation features (for example, incremental transformation), we recommend using a dedicated transformation tool, such as [dbt](https://www.getdbt.com/) or [Dataform](https://cloud.google.com/dataform).
:::
## Create a Query Model
To create a new query model, go to the Development, click on the `+` symbol next to a folder, and select **Add Query Model**. A new screen will appear, and from here you can write the query of the model.
After running the query and save the model, a new `.model.aml` file will be created.
### Query Model Syntax
:::info
Please refer to [AML Query Model Reference](/reference/aml/query-model) to learn more about all available parameters and example usage.
:::
Below is a sample of how the Query Model syntax will look like. All of these codes will be generated when you create the model using the graphical UI, but you can also create the query model manually by writing from scratch:
```aml title="model_name.model.aml"
Model model_name {
type: 'query'
label: "Model Label"
description: ""
owner: 'user@your-domain.com'
data_source_name: 'data_source_name'
models: [model_a, model_b]
query: @sql
select
{{ #model_a.field_name }}
FROM {{ #model_a }};;
dimension dimension_name_1 {
// dimension 1 definition
label: 'Dimension Label 1'
type: 'text' | 'number' | 'date' | 'datetime' | 'truefalse'
description: 'Field Description'
hidden: true|false
definition: @sql {{#SOURCE.column_name}};;
}
measure measure_name_1 {
// measure 1 definition
label: 'Measure Name 1'
type: 'number'
definition: @aql count(model_name.dimension_name) ;;
}
}
```
## Model Persistence
:::info
Please refer to [Query Model Persistence](/docs/persistence) document to learn more about all persistence modes and how they work.
:::
With this feature, you can execute the model's query at scheduled intervals, and store the result in a database table.
To turn on Persistence for your Model, add `persistence` param to your query model:
```aml
Model orders {
type: 'query'
label: "Orders"
data_source_name: 'demodata'
owner: 'analyst@holistics.io'
query: @sql select * FROM ecommerce.orders ;;
// Specify persistence mode here
persistence: FullPersistence {
schema: 'persisted'
on_cascade: 'rebuild'
}
}
dimension id {
label: 'Id'
type: 'number'
}
// dimension ...
}
```
You will also need a `schedules.aml` file in the **root folder** of your AML project with the following content:
```aml
const schedules = [
// Schedule orders model to run every 10 minutes
Schedule { models: [orders], cron: '0,10,20,30,40,50 * * * *' }
]
```
## Effects of Model Persistence
The following sections will describe the effects of Model Persistence on the queries generated from your models.
### With Persistence
The Query Model will now be backed by a physical table in your data warehouse. The table will be updated with new data following a schedule of your choice. Think of this similar to **"materialized view"** concept in standard SQL databases.
When you refer to a persisted model in another transformation, you will be querying from the table instead of rerunning the whole transformation sequence. The final query will look like this:
```sql
select field_1, field_2, calculation_1 from persisted_model_a
```
:::info Pros & Cons
- **Pros:** Reduce the amount of actual data scanned when you explore data from this model -> lighter load on your system, and shorter query time.
- **Cons:** Data can be stale or persistence schedule between models can be mismatch, which produces wrong results.
:::
### Without Persistence
The model will now **resemble a "view" in your database**. When you refer to a non-persistence model, the model's full SQL will be inserted in the final SQL and you will rerun the whole transformation sequence.
When querying a "non-persisted query model", the compiled SQL will usually contain a CTE (SQL `WITH` statement). Something like:
```sql
with model_a as (
select
field_1
, field_2
, some_calculation as calculation_1
from source_table
)
select
field_1
, field_2
, calculation_1
from model_a
```
:::info Pros & Cons
- **Pros:** Have visibility of the whole transformation sequence
- **Cons:** Possible higher load to your database, and slower query.
:::
### When should I use Persistence?
Here is the general rule of thumb to help you decide whether to turn on Model Persistence for your models:
✔️ You **should** use Model Persistence when:
- It is an upstream model that runs slowly (due to complex query, or large amount data is scanned)
- It is a downstream model that your end users will explore frequently. This way they can have a faster exploration experience.
❌ You **should not** use Model Persistence when:
- The transformation makes little changes to the data (mostly renaming, concatenating...)
- You need to ensure a series of data transformation use and produce absolutely up-to-date data.
## Query Parameters
Typically, when you filter on a query model, the filtering happens as follows:
1. Holistics executes the SQL of the model without filter, and returns a result set
2. Filter condition is applied **on the result set**
In many cases, this behavior is not desirable (for example, when the underlying table is large). With **Query Parameters**, users can insert the filtering condition directly into the model's query:
```sql
# 'order_aggregated' model
select
order_date,
count(*) as order_count
from orders
group by 1
# Apply "Order Date = '2024-01-01'" filter from dashboard
with order_aggregated as (
select
order_date,
count(*) as order_count
from orders
group by 1
)
select
order_date,
order_count
from order_aggregated
where order_date = '2024-01-01' # Filter outside of CTE
```
```sql
# 'order_aggregated' model
select
order_date,
count(*) as order_count
from orders
where {% filter(order_date_param) %} orders.order_date {% end %}
group by 1
# Apply "Order Date = '2024-01-01'" filter from dashboard
with order_aggregated as (
select
order_date,
count(*) as order_count
from orders
where order_date = '2024-01-01' # Filter inside of CTE
group by 1
)
select
order_date,
order_count
from order_aggregated
```
:::info
Please refer to [Query Parameters](/docs/query-parameters) document for more details about this feature.
:::
## Model Dependencies
Holistics uses the modeling syntax to know the dependencies between SQL models and determine the sequence of transformation.
For example, when you run the following query, Holistics knows that model `order_master`, `ecommerce_orders` and `ecommerce_users` must be executed first.
```js
with base as (
select
{{ #o.user_id }}
, {{ #oi.orders_count }} as total_orders_count
, {{ #oi.delivered_orders_count }} as delivered_orders_count
, {{ #oi.gmv }} as gmv
, {{ #oi.nmv }} as nmv
, min( {{ #o.order_created_date }} ) as first_order_date
, max( {{ #o.order_created_date }}) as last_order_date
, current_date - max( {{ #o.order_created_date }}) as days_from_last_order
from {{ #orders_master oi }}
left join {{ #ecommerce_orders o }} on {{ #o.id }} = {{ #oi.order_id }}
group by 1
)
select
{{ #u.id }} as user_id
, total_orders_count
, delivered_orders_count
, gmv
, nmv
, first_order_date
, last_order_date
, days_from_last_order
from {{ #ecommerce_users u }}
left join base on {{ #u.id }} = base.user_id
```
If you save the query to a model called `user_facts_aggr`, Holistics can generate a dependency map for that model.
Changes you made in the parent model will be carried over subsequent models. If in model `orders_master` we changed the definition of `gmv` field and got different values, this change will reflect to `user_facts_aggr`.
## Troubleshooting
### Effects when changing model structure
When editing a query model, if you change the structure of the result set (add fields, remove fields...), there will be side effects:
- If your query model has persistence, the persistence will be reset
- Removing/renaming a field will:
- Invalidate any custom dimensions or measures that are referring to it in their formulas
- Invalidate any relationships that are pointing to that field
- Break any query model or report that refers to that field
- Merely changing a field's data type will not reset the persistence setting. However, it can cause unforeseen issues in custom dimensions, measures and models using that field.
### Broken downstream models
A query model can be broken when the structure of its upstream model is changed. For example:
- When the model's query refers to fields/measures that are no longer available, or invalid in the upstream model.
- When the upstream model is invalid.
---
## Quickstart Holistics
These steps outline what you need to do to start incorporating Holistics as your self-service Business Intelligence tool.
1. **Connect:** You connect Holistics to an existing SQL data warehouse.
2. **Model & Transform**: You use Holistics Data Modeling to model and transform analytics data.
3. **Dataset Building:** You build datasets (a combination of data models)
4. **Self-service Analytics:** Non-technical users can self-service explore data based on datasets prepared by data teams, or build interactive reporting dashboards.
5. **Sharing:** Dashboards can be shared with others, or pushed to other platforms (email, Slack, webhooks, etc)
VIDEO
---
## Quickstart
## Introduction
Building analytics in Holistics follows a structured workflow that turns raw database tables into dashboards and reports. The workflow cleanly separates **development** from **production**, so you can iterate safely without affecting your end users.
The journey progresses through three layers:
| Layer | What You Build | Purpose |
|-------|---------------|---------|
| **Data Sources** | Database connections | Connect to your data warehouses |
| **Modeling** | Data models, datasets | Define business logic and relationships |
| **Reporting** | Explorations, ad-hoc reports, dashboards | Visualize and share insights |

This guide walks you through each step of the development workflow:
1. [Connect to a database](/docs/quickstart#1-connect-to-a-database)
2. [Enable development mode](/docs/quickstart#2-enable-development-mode)
3. [Develop data models](/docs/quickstart#3-develop-data-models)
4. [Develop datasets](/docs/quickstart#4-develop-datasets)
5. [Develop dashboards](/docs/quickstart#5-develop-dashboards)
6. [Commit changes and publish](/docs/quickstart#6-commit-changes-and-publish)
7. [Explore data and build reports](/docs/quickstart#7-explore-data-and-build-reports)
## 1. Connect to a database
Your database connection is the foundation of everything in Holistics. All data models, datasets, and reports build on top of the tables in your connected data warehouse.
:::info
For detailed setup instructions, see [Connect to a Database](/docs/connect/).
:::
Before you can start modeling, make sure you have connected to at least one database. Holistics supports a wide range of databases including PostgreSQL, MySQL, BigQuery, Snowflake, and more.
## 2. Enable development mode
The Development/Production separation is what makes it safe to iterate on your analytics. In Development Mode, you can freely modify models, datasets, and dashboards without affecting the reports your end users are viewing in Production.
:::info
For more details on the two modes, see [Mode & Deployment](/docs/development/dev-prod-mode).
:::
To enable Development Mode, navigate to the **Development workspace** and click the toggle to switch from **Production Mode** to **Development Mode**.
## 3. Develop data models
Data models are abstract representations of your database tables. They let you define business logic, add calculated fields, and enrich raw data with metadata, all without modifying the underlying database.
:::info
For detailed instructions, see [Data Models](/docs/data-model) and [AML Model syntax](/reference/aml/model).
:::
Holistics supports two types of data models:
| Model Type | Description | Use Case |
|------------|-------------|----------|
| **[Table Model](/docs/table-models)** | Maps directly to an existing database table | When you want to expose a table as-is with added metadata |
| **[Query Model](/docs/query-models)** | Defined by custom SQL that selects from tables or other models | When you need to clean up data, perform calculations, or define reusable business logic in SQL |
You can create models using either the visual GUI or by writing AML code directly in the Development workspace.
**Creating a Table Model:**
**Creating a Query Model:**
## 4. Develop datasets
Datasets connect your data models together with relationships, making them available for exploration. Think of a dataset as a curated collection of related models that business users can query without writing SQL.
:::info
For detailed instructions, see [Datasets](/docs/datasets) and [AML Dataset syntax](/reference/aml/dataset).
:::
A dataset includes:
- **Models**: The data models included in the dataset
- **Relationships**: How models connect to each other (e.g., `orders.user_id > users.id`)
In Holistics, you can design your dataset following proven [modeling patterns](/docs/modeling-patterns) like star schema, galaxy schema, or snowflake schema depending on your analytical needs.
Once published, datasets appear in the Reporting tab where users can explore them with a drag-and-drop interface.
## 5. Develop dashboards
Dashboards combine visualizations, filters, and interactive controls into a single view for end users. In Holistics, dashboards are defined in AML code, which gives you full control over layout, interactions, and styling with the benefits of version control and reusability.
:::info
For detailed instructions, see [Canvas Dashboard](/docs/dashboards/) and [AML Dashboard syntax](/reference/aml/dashboard).
:::
A dashboard consists of:
- **Blocks**: Visual components like charts (VizBlock), text (TextBlock), filters (FilterBlock), and date drills (DateDrillBlock)
- **Interactions**: Define how blocks interact with each other (e.g., filters affecting charts)
- **Views**: Control the layout and positioning of blocks on the canvas
- **Settings**: Configure timezone, caching, and other dashboard-level options
To create a dashboard, add a new file with the extension `.page.aml` (e.g., `my_dashboard.page.aml`) in the Development workspace.
## 6. Commit changes and publish
This is where your work moves from development to production. Publishing makes your models, datasets, and dashboards available to end users in the Reporting tab.
### Commit changes
:::info
This step is necessary only if you're using [Holistics Git Flow](/docs/git-version-control). Otherwise, skip directly to [Publish](/docs/quickstart#publish).
:::
If you've connected an external Git repository, commit your changes before publishing. Click **Commit changes & Push**, update the commit message if needed, and then **Commit and push**.
### Publish
Click **Publish** to deploy your changes to production. Your datasets and dashboards will now be available in the Reporting tab.
## 7. Explore data and build reports
Your data is now ready for exploration. After publishing, your datasets appear in the **Reporting** tab where you and your team can start discovering insights.
With published datasets, users can:
- **Explore with drag-and-drop**: Select dimensions and measures, apply filters, and visualize data without writing SQL
- **Ask Holistics AI**: Use natural-language questions to query your governed semantic layer through [Holistics AI](/docs/ai)
- **Create visualizations**: Build charts, tables, and other visualizations from your curated datasets
- **Save to dashboards**: Capture meaningful explorations and add them to dashboards for ongoing monitoring
:::info
To learn more about what you can do with your data, see:
- [Data Exploration](/docs/data-exploration): Learn the drag-and-drop exploration interface
- [Holistics AI](/docs/ai): Ask questions in natural language against your governed semantic layer
- [Visualizations](/docs/visualizations/): Explore available chart types and formatting options
:::
## 8. Share data
Insights are useful when they reach the people who need them. Holistics gives you several ways to distribute the dashboards and reports you build:
- **Dashboards**: Share with your team inside the Reporting workspace.
- **Scheduled deliveries**: Send dashboards or data extracts on a schedule via email, Slack, Google Sheets, or SFTP. See [Sharing data](/docs/delivery/export-data).
- **Embedded analytics**: Embed dashboards and AI in your own product through the [Embedded Analytics](/embedded/) suite. The same governed semantic layer powers internal and customer-facing surfaces, so one definition serves both.
## FAQs
### What happens if my modeling changes break existing dashboards and reports?
When you modify models or datasets, downstream dashboards and reports may be affected:
- **Dashboards in [Public workspace](/docs/admin/permission-system#public-workspace)**: You'll need to fix errors before publishing. This ensures shared content remains functional for all users.
- **Dashboards in [Personal workspace](/docs/admin/permission-system#personal-workspace)**: You can still publish, but you'll receive a notification about which personal dashboards are broken.
The broken dashboard in Personal workspace will look like this in Reporting:
:::info
[Personal Canvas dashboards](/docs/admin/permission-system#personal-workspace) can only be viewed and edited in **Reporting**, not in **Development**. If you need to fix a broken personal dashboard, contact [support@holistics.io](mailto:support@holistics.io).
We're considering adding support for personal Canvas dashboards in Development in the future.
:::
---
## Reference Lines
## Introduction
Reference lines provide visual comparison between data in a chart against a preset value.
It can be a **constant value** (e.g. 1000) - which you can use to compare monthly sales to a target number and display it as a goal line, or a **computed value** (e.g. average of sales) - which you can use to compare sales of each product against the average.
Reference Lines are supported in **Line, Column, Bar, Area, and Combination Charts.**
You can add a Reference Line in the visualization with just a few clicks, and you can create as many lines as you want. Here is a sneak peak of how it will look like:
## Displaying one or multiple reference lines
In charts with a Legend, Holistics automatically generates individual reference lines for each legend value, which results in multiple lines. You can choose to display a "global" reference line by using either **break down by legend** toggle or **merge into one line** toggle, whichever suits your analytical needs.
Please note that both toggles are available only in AQL-enabled datasets.
### Option 1: Turn off the “break down by legend” toggle
- This setting removes the Legend’s influence on the reference line. When the "Break down by legend" toggle is off, the reference line mimics what you would see in a chart without any legend.
- Opt for this when your goal is to display the reference line for overall totals, ignoring the segmentation by the Legend. For example, comparing the performance of each category against the average of the monthly totals.
- Here’s an example for what “break down by legend” toggle can do:
- Image 1 is a visualization with no Legend at all.
- Image 2 is a visualization with a Legend “Gender”, but the reference line’s toggle “break down by legend” is turned off.
- You’ll see that the purple reference lines in both images have the same value of 494.
### Option 2: Turn on the “merge into one line” toggle
- This toggle calculates a single reference line by considering all the data points in the chart.
- It is ideal for presenting a comprehensive reference line that spans across the segmented values. For example, comparing the performance of each category against the average of all categories within each month.
- Here’s an example for the comparison of Option 1 and Option 2:
- Image 1 is the visualization with a Legend “Gender”, but the reference line’s toggle “break down by legend” is turned off.
- Image 2 is a visualization where we turn on both toggles “break down by legend” and “merge into one line” for the reference line.
- You’ll see that the dark blue reference line in the second image has a value of 259, compared to the purple reference line, which is 494 in the first image. This is because the dark blue reference line is calculated as the average of all the segmented data points in the blue and green columns.
---
## Build Relationships
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Data Model in Holistics](/docs/data-model)
- [Dimensions and Measures of a Data Model](model-fields)
:::
Once you've created your data models, you can specify **how they should be linked together** by setting up Relationships.
This step is needed for Holistics to build the right SQL query when you combine fields from different models to create a report. For example, given a dataset with `orders`, `users`, `cities`, and `countries`, to calculate "Total orders by customers' countries" Holistics walks the relationship chain and generates the appropriate JOINs:
```sql
SELECT
T3."name" AS "name",
count(1) AS "total_orders"
FROM
"ecommerce"."orders" T0
LEFT JOIN "ecommerce"."users" T1 ON T0."user_id" = T1."id"
LEFT JOIN "ecommerce"."cities" T2 ON T1."city_id" = T2."id"
LEFT JOIN "ecommerce"."countries" T3 ON T2."country_code" = T3."code"
GROUP BY 1
ORDER BY 2 DESC
```
## Creating a relationship
:::info
Please refer to [AML Relationship](/reference/aml/relationship) to learn more about all available parameters and their example usage.
:::
In **Holistics 4.0**, a relationship can be defined **within a dataset** or **as separate relationship files**. You can either use the interactive UI to create a relationship, or define them programatically using AML syntax.
### Define relationship inside a dataset (using Interactive UI)
This option is only available inside a **[Dataset](/docs/datasets)**
The generated AML syntax will be like below:
```typescript
Dataset ecommerce {
...
models: [
orders,
users,
order_items,
countries,
merchants,
products,
cities,
param_model
]
relationships: [
relationship(order_items.order_id > orders.id, true),
relationship(order_items.product_id > products.id, true),
relationship(products.merchant_id > merchants.id, true),
relationship(merchants.city_id > cities.id, false),
relationship(orders.user_id > users.id, true),
relationship(users.city_id > cities.id, true),
relationship(cities.country_code > countries.code, true)
]
}
```
### Define relationship in a separate file (using AML syntax)
You can define the relationship in a model file or [define the relationship as a separate file](/reference/aml/relationship#defining-reusable-relationships).
```tsx
//Relationship defined in relationship file: relationships.aml
Relationship order_items_products {
type: 'many_to_one'
from: FieldRef {
model: 'order_items'
field: 'product_id'
}
to: FieldRef {
model: 'products'
field: 'id'
}
}
```
And then, when you want to add that relationship to the Dataset, simply reference the relationship name and indicate whether you want it enabled or disabled.
```typescript
Dataset ecommerce {
...
models: [order_items, products]
relationships: [
//highlight-next-line
relationship(order_items_products, true),
]
}
```
Learn more about Relationship AML syntax by visiting [AML Relationship](/reference/aml/relationship).
## Automatic relationship creation
In some databases where foreign key constraints are already implemented, Holistics will automatically detect these constraints and turn them into relationships.
Currently, this feature is available for the following databases:
- PostgreSQL
- MySQL
- Microsoft SQL Server
- Redshift
## How Holistics builds JOINs
When you use fields from different models in a report, Holistics walks your relationship definitions to generate the correct JOIN statements. By default, Holistics uses `LEFT JOIN` for both `many-to-one` and `one-to-one` relationships. For `many-to-one`, the model on the "many" side goes on the left of the join.
If you have verified that a relationship's keys always match (no NULL or orphaned foreign keys), you can add `nullable=false` to the relationship so Holistics generates a faster `INNER JOIN` instead. See [How Holistics handles joins](/docs/joins/how-joins-work#nullable-relationships) for the behavior and its trade-offs.
You may also see `SELECT DISTINCT` in the generated SQL. This happens for two reasons: to prevent fan-out (see below) and to avoid showing duplicate rows where the underlying data contains them.
### Path ambiguity
When multiple join paths exist between two models, Holistics automatically picks the most appropriate one using a ranking algorithm that considers relationship patterns, explicit specifications, and path complexity. For detailed information see [Path Ambiguity in Dataset](/docs/joins/path-ambiguity).
## Edge cases
### Composite keys
There are cases where models must be joined on two keys. For example, last year's sales aggregation by cities vs. this year's sales aggregation by cities:
The intended query is:
```sql
select
ty.city
, ty.month
, sales_this_year
, sales_last_year
from this_year ty
join last_year ly
on ty.city = ly.city and ty.month = ly.month -- using two keys
```
In Holistics, to replicate this behavior, you can concatenate the individual keys to create compound keys (using [Custom Dimensions](/docs/model-fields)), and add a relationship on these fields.
With this approach, the generated query will be:
```sql
select
ty.city
, ty.month
, sales_this_year
, sales_last_year
from this_year ty
join last_year ly
on concat(ty.city, ty.month) = concat(ly.city, ly.month) -- using one compound key
```
### Many-to-many (n-n) relationships
It is not possible to specify a many-to-many (n - n) relationship directly. To join models with n - n relationships, you will need a junction model, which you can create with a [Query Model](/docs/query-models), so that the relationship is interpreted as 1 - n - 1.
Consider the following case where you have two models: `products` and `merchants`. Products can have multiple merchants, and a merchant can provide many products.
To join these two models, you will need a `products_merchants` junction model:
After that, you are good to explore datasets with those 3 data models.
### Fan-out issues
When you set relationships between models (many-to-one or one-to-one), we assume that the fields at the "one" end are already unique.
If the field is not unique, when you drag in fields from those two models, a fan-out will happen. The result set will be a Cartesian product of the two models and may "explode" into millions of rows, triggering a `fan-out error` in the Data Exploration window.
Visit [Cannot combine fields due to fan-out issues?](/docs/joins/troubleshooting-fanout) to learn more about this error and how to troubleshoot it.
---
## Troubleshoot slow reports
Are you experiencing performance problems with your Holistics dashboards? This post explains potential reasons why your dashboards might take a long time to load, and what can you do about it.
:::info
Make sure you understand [what happens behind the scene when a report runs](/docs/performance/report-running-mechanism).
:::
## Common reasons that make your report job slow
### Job waiting in the Queue for a long time
Holistics uses a [job queue system](/docs/jobs/queues-and-workers) to manage incoming report requests. There is a finite number of workers (to process jobs) in a queue.
As a result, if there are many jobs filing to the system at the same time (more than your current job queue size can handle), new jobs will likely wait for a long time to be processed.
To troubleshoot, go to your [Jobs Monitoring](/docs/monitoring/job-monitoring) dashboard and use the [Status Filter](/docs/monitoring/job-monitoring#filtering). `All unfinished statuses` and check the jobs which fall under that category.
:::info
**All unfinished statuses** filter includes jobs that have one of these statuses:
- Pending
- Starting
- Running
:::
See [Life Cycle of a Job](/docs/jobs/queues-and-workers#life-cycle-of-a-job) for more info about Job statuses.
### Query taking a long time to run on your database
Holistics doesn’t store or process your raw data. Instead, for every report request, Holistics generates a SQL query and run them against your data warehouse. This means that your report’s performance depends on how fast/slow your data warehouse runs the SQL query.
Based on our experience, this step usually **takes the most time** in the report execution.
**What are possible reasons for a long-running query?**
- Complex SQL queries (usually involve many joins)
- Querying tables with a large amount of records
- Your database is experiencing a high workload.
- Show-rows-with-no-data is enabled
⇒ Enabling this option may slow down report performance because Holistics needs to **execute additional queries** to display rows with no corresponding data.
**How can I troubleshoot this issue?**
Head over to [Investigate and troubleshoot data warehouse issues](/docs/performance/troubleshooting) for detailed instructions on how to deal with this issue.
### Result set taking a long time to transfer from your database to Holistics servers or from Holistics servers to your browser
Because Holistics is a cloud-based solution, there will be some over-the-network transferring of data between these servers: Your data warehouse → Holistics servers → End user’s browsers.
A high latency network (usually due to long distances) will add to the report loading time. The problem is made worse when the query’s result set contains a large number of records.
**How can I troubleshoot this issue?**
- Check the number of records returned from the query’s result set.
- Check and compare your DW server’s locations with Holistics’ server location and end user’s location. If you believe this is the problem, consider [migrating to another region](/docs/security-compliance/data-centers#migrating-to-another-region).
### Additional overheads in pre-processing and post-processing on Holistics’ side
Because Holistics is a **Business Intelligence** tool with complex business and visualization logic involved, it requires some additional processing such as:
- Data Access Control
- Resolving row-level/column-level permissions
- Timezone check and shifting logic
- For special visualizations like Period-over-Period or Metric Sheet, extra logic is appended to the query plan
- etc.
Because of these operations, Holistics may appear to be not as fast as other (more) simplistic SQL/querying tools whose features are limited to only executing queries and displaying the raw queries’ results. However, they are crucial for Holistics to serve as a dynamic, powerful, and all-round **Business Intelligence** application.
Nevertheless, Holistics team does not take this for granted. We acknowledge the performance overhead as a trade-off and are constantly working to enhance the experience.
## What should I do next?
- Consult the [Best Practices to improve Reporting Performance](/docs/performance)
- Check out the available [Job Controls](/docs/jobs/controls)
- If you have applied all of our best practices but are still unsatisfied with the performance, please help us:
- Collect jobs that you find underperforming on the [Job Monitoring page](/docs/monitoring/job-monitoring).
- Then, share it with us via support@holistics.io with [these details](/docs/jobs/report-slow-jobs). Our Support team is happy to assist you with your case.
---
## Dashboard Period Comparison
## Introduction
Holistics’s Period Comparison capability enables non-technical users to visually compare data from different time periods, e.g: comparing revenue data this quarter with same quarter last year.
In Holistics, you can set up Period Comparison at two different levels: **Dashboard level**, and **Widget level**.
This article explains how to work with Dashboard-level Period Comparison (or Dashboard Period Comparison). For Widget-level Period Comparison, please refer to this document: [Period Comparison](/docs/period-comparison).
## When does Dashboard Period Comparison get useful?
As Dashboard Period Comparison operates on the dashboard level, it offers certain flexibilities of a dashboard interactive feature, similar to Dashboard Filter. These include:
- Creators can selectively map several widgets to the same comparison at once
- Dashboard viewers can change the comparison period
## High-level Mechanism
### Comparison mechanism: Previous period & Custom period
Dashboard Period Comparison supports two types of comparison:
- Previous period: a relative time shift from the original timeframe *(Example: compare with **2 months ago**)*
- Custom period: an absolute timeframe chosen by you *(Example: compare with range **2022-05-24 to 2022-08-11**)*
#### 1. Previous period Comparison
Similar to Widget-level Period Comparison’s [mechanism](/docs/period-comparison#how-it-works), Dashboard Period Comparison will generate an additional data series, which is similar to the original one but shifted back by the chosen period. These two series will then be overlaid on top of each other in the chart.
#### 2. Custom period Comparison
Dashboard Period Comparison will generate another data series from the chosen arbitrary timeframe, and put it next to the original series. These two series will then be overlaid on top of each other in the chart.
### Mapping to a widget(s)
Once enabled for a widget(s), Dashboard Period Comparison will apply its comparison mechanism to any Date fields it is mapped to. Thus, it will update the comparison period according to the original period in each widget.
*Example:*
- *You map a Previous period Comparison of `2 months ago` to two widgets - A and B. Widget A's original period is filtered to `08/2023`, while widget B's period is filtered to `05/2023`*
- *After applying the Period Comparison, the comparison period will be `06/2023` for widget A, and `03/2023` for widget B.*
## Tutorial
You can set up a Dashboard Period Comparison following these steps:
1. **On Widget’s Settings**: Select at least one aggregated field (or measure) from the dataset
2. **On Dashboard**: Click on the “Add Control” button, and choose “Period Comparison” from the drop-down menu
3. **On the “Add Period Comparison” modal**:
- Select the Comparison type & Period to compare
- Enable the widgets you want to map to
- Choose a date field for comparison in each enabled widget
## Acknowledgments
- Map & Override Behavior:
- Each widget can be mapped to only one Dashboard Period Comparison at the same time
- If a widget already has Period Comparison in its settings, Dashboard Period Comparison will override it
- Databases & Visualization types that Dashboard Period Comparison is supported for:
- More information in [Period Comparison](/docs/period-comparison#other-notes)
- When you save an exploration as a dashboard widget, any Dashboard Period Comparison at the moment will be retained as Widget Period Comparison.
## FAQ
### Dashboard Period Comparison for non-time-series (categorical) chart
- Previous period Comparison
- For non-time-series charts, you need to set a filter on the Period Comparison's Date Field to narrow down the range. Without a filtering condition, two periods will return the same result.
- More information in: [Period | Applying date filter for non-time-series charts](/docs/period-comparison#for-non-time-series-charts).
- Custom period Comparison
- You can perform Period Comparison Custom period on non-time-series charts, since Holistics already requests you to determine a comparison period beforehand.
---
## Period-over-period Comparison
## Introduction
With Period Comparison feature, users can quickly visualize and compare data of different periods without complicated modeling steps.
For example, comparing sales of this year with the previous year:
## Set up
You can set up Period Comparison as part of a visualization, allowing users to see the comparison immediately when they visit the dashboard. Additionally, you can set up Period Comparison as a control on your dashboard, enabling users to interact with it for different timeframe comparisons.
### When exploring a dataset or building a visualization
In the dataset exploration (or visualization editing) interface, there are two options to set up Period Comparison:
1. Click on a measure/metric in the Settings and choose **Add calculation: Period-over-period comparison.**
2. Turn on **Quick period comparison toggle** at the bottom of the Settings.
*In all cases, we recommend using the first option.* Here's why:
- The second option, the Quick Period Comparison toggle, is an early feature designed to support period-over-period (PoP) analysis. However, from customer feedback over time, we've found it to be limited in flexibility and customizability.
- With our robust AQL foundation, the Period-over-period comparison calculation utilizes AQL's power and an intuitive UI to accommodate all use cases that the Quick Period Comparison cannot. For instance:
Please see below for detailed instructions for each option.
**Period-over-period Comparison Calculation:**
1. Input at least one measure/metric in your visualization.
2. Click **Add calculation: Period-over-period comparison.**
3. Choose one option from our quick suggestions, or select Custom to create your own.
4. Adjust the field label, styling, or formatting as desired.
:::info Feature Prerequisites
- **4.0 Version**: If you are currently using version 3.0, you'll need to [upgrade to 4.0 version](/as-code/3.0-to-4.0-migration) to use this feature and other cool stuff.
- **AQL-Enabled**: Once your version is upgraded to 4.0, make sure you have [enabled AQL for your datasets](/as-code/aql/enabling-aql).
:::
:::tip Alternative
This is the built-in, UI-centric functionality to create period-over-period comparisons. Alternatively, you can use AQL expressions as detailed here: [Period Comparison Guide using AQL](/as-code/aql/cookbook/aql-period-comparison).
:::
**Quick period comparison toggle:**
1. Input at least one measure/metric in your visualization
2. Toggle on **Quick Period Comparison** in Visualization Settings
3. Select a date/time field to act as the **time dimension of the comparison**
4. Select Comparison Type & Period to compare
### When building a dashboard
1. **On Dashboard**: Click on the **Add Control** button, and choose **Period Comparison**.
2. **On the “Add Period Comparison” modal**:
- Select the Comparison type & Period to compare
- Select the widgets that you want to apply the comparison, and choose a date/time field to act as the **time dimension of the comparison.**
:::info Notes
- Currently, the Period Comparison control on dashboards is similar to the Quick period comparison toggle in the dataset exploration flow described above. We are working on an improved version for dashboards.
- Each widget can be mapped to only one Dashboard Period Comparison at a time
- If a widget already has Period Comparison in its settings, Dashboard Period Comparison will take precedence when you view it in dashboard mode. The widget's Period Comparison remains in tact, and will show when you Explore or Edit the widget.
:::
### For non-time series charts
Holistics's Period Comparison also works on non-time series charts.
However, since they display aggregated data without a time dimension, the setup will need a few extra steps.
- If you use **Previous period comparison**, you will need to **specify a base time period** so that Holistics can take that period and shift it back. In other words, you need to **add a time filter** to your data. Without a filtering condition, two periods will return the same result.
- If you use **Custom period comparison**, without filtering on the base time period, the feature still works. You will compare metrics **calculated on all time** against metrics calculated on a custom time period.
## How it works
Period Comparison supports two types of comparison:
- **Previous period:** a relative time shift from the original timeframe. For example: compare this month with **2 months ago**
- **Custom period:** an absolute timeframe chosen by the user. For example: compare this week with the **Christmas period (2023-12-24 to 2024-01-02**)
We will go into details of how each type works in the next sections.
### Previous period comparison
**Previous period comparison** will take the base time period, shift it back by an amount of your choice (1 month, 1 year...), calculate your metrics at that shifted time period, and display the time points of base period and past period **in parallel**. For example:
- Previous 1 year comparison: June 2023 is compared to June 2022
- Previous 1 week comparison: This Friday is compared to last week's Friday
### Custom period comparison
**Custom period comparison** will calculate the metrics at the custom selected time period, and the two time periods are displayed together so that **the first time points are aligned**. Time points in both periods are sorted in the same order.
In the following example, the custom period (09/2022 -> 12/2022) is compared with the base period (01/2023 -> 06/2023). Data points are sorted in descending time order, and the month **12/2022** is aligned with the month **06/2023** as they are both at the first position of their respective period.
## Availability
### Chart types supported
The following chart types support Period Comparison:
- Table
- Pivot Table
- Line/Bar Chart family: Line/Bar/Column/Area/Combination chart
- Metric KPI
### Databases supported
Period Comparison is supported in the following databases:
- PostgreSQL
- Google BigQuery
- Amazon Athena
- Snowflake
- Presto
- Amazon Redshift
- MySQL
- Microsoft SQL Server
- Clickhouse
## Other notes
### Date filters only apply to the base period
If users want to limit the data from both periods for privacy purposes, we suggest using [Row-level Permission](/docs/access-control/row-level-permission).
### When base and past periods have different length
For example, we compare the base month with previous 1 month.
- If the base month has **more days** than the previous month (March vs. February), then the line for previous month will show blank for the missing days:
- If the base month has **less days** than the previous month (February vs. January), then the chart will cut off at the end of the base month:
### Comparing weeks of years
If the data is aggregated to **Week** grain and is to be compared with **previous 1 year**, we will do the following:
1. We convert the weeks into **seven-day blocks**. The first day of the week follows [Week Start Day setting](/docs/datetimes/week-start-day).
2. We shift those **seven-day blocks** back one year to create the past period
3. The metrics calculated for this past period will be aggregated by these **seven-day blocks** and may not represent a normal week (that starts from Monday and ends with Sunday)
In other words, Week 1 of this year may not be aligned with the days of Week 1 last year, but is aligned with the **seven-day block** that happened exactly 1 year ago.
For example, when comparing weekly Revenue of January 2021 and February 2021 with their previous 1 year. The Week Start Day is Monday.
---
## Reused Blocks
## Reused blocks
A **Reused Block** is an Analytics Block that is explicitly and programmatically defined by users to be used in Canvas Dashboards.
### Why do you need Reused Blocks?
Normally, when you use Visual Editor to develop Canvas Dashboards, Holistics takes care of code generation under the hood to ensure that the generated code correctly reflects how your dashboard works. There are multiple ways to programmatically achieve the same outcome, so we pick a particular path to generate code so that you don't have to worry about it.
But there are cases where you may want to explicitly define Analytics Blocks as code yourself:
- [Build Reusable Components in Canvas Dashboard](/docs/canvas-dashboard/reusable-components)
- [Build a Dashboard with Multiple Similar Charts](/docs/canvas-dashboard/build-similar-dashboards)
- [Customize Client Dashboards](/embedded/dashboard-templates)
### What happens when you define Reused Blocks?
When you explicitly define an Analytics Block as a reusable piece of code, it becomes a **Reused Block** which cannot be edited using the Visual Editor. You must switch to code mode and edit the code yourself. This behavior aims to avoid accidentally replacing user-defined code with auto-generated code.
---
## Running Total
:::info
**Running Total** is not supported for ClickHouse database in Holistics.
:::
## Introduction
A Running Total is a common calculation method that shows how a metric has changed over time. In Holistics, we use the word "Total" in its broader sense, which includes: Sum, Average, Min, Max, etc.
For example, you might want to use a **running sum** to calculate the accumulated amount of goods sold up to a specific quarter.
## How to create a Running Total
- Drag or type the field's name into the Visualization Settings
- Click on it to display the [aggregation drop-down](/docs/expression#aggregator-functions). Here you can choose among four currently supported types of Running Total: Sum, Average, Min, Max.
- Click Get Result.
## Mechanism
A Running Total consists of two main components:
- Running Dimensions: The dimensions that the total will “run along” with. Each running direction will have a direction of its own.
- Measures: The measures that you want to calculate the running total.
The following part will give you a detailed look into running dimensions.
### Running Dimensions
#### Definition
The running dimensions are the dimensions based on which the measures are aggregated.
Let's take our previous example again: when we calculate the running sum of quantity of goods sold up to a specific quarter, the quarter is the Running Dimension. It means Holistics will stack up the quantity of goods sold from one quarter to another.
:::info
Holistics automatically selects all the date dimensions as the running dimensions and sorts by the most granular date dimension.
:::
#### Grouping
The Running Total will be grouped by all non-date dimensions.
Let's say you have a date field, a category field and a running sum of quantity of goods. The running sum will be stacked up by quarter, and grouped by each category. In other words, the Running Total will start all over again for each category.
#### Running dimensions and filter
The running total is recalculated after the filter/condition is applied to the running dimensions.
### The direction of each running dimension
The direction of each running dimension determines whether Holistics will calculate the Running Total by ascending or descending order of that dimension.
:::info
Holistics calculates the Running Total in ascending order of all date dimensions in the report. Contact us if you want to change the direction of each running dimension.
:::
## On our roadmap
To cater to more advanced use-cases, we have the following features in our plans:
- Ability to choose your preferred running dimensions
- Ability to choose the order of running dimensions
- Ability to change the direction of each running dimension
If you need one of the aforementioned features, please share with us your use-case in our community so we can prioritize it in our backlog.
---
## Data centers
## Holistics’ data centers
Holistics currently offers three data centers. All of them are hosted with reputable data center providers:
- Our Asia-Pacific servers (APAC) are located in Singapore
- Our Europe servers (EU) are located in Frankfurt, Germany
- Our US servers (US) are located in San Francisco
## Whitelist Holistics’ data center IP addresses
If you're using [Direct Connection](/docs/connect/connect-direct) method, you might need to whitelist Holistics' IP addresses to connect properly.
Here are the IP addresses to whitelist depending on your data center:
- **APAC**:
- 188.166.198.14
- 188.166.196.151
- **EU**:
- 157.245.16.185
- 157.245.16.186
- **US**:
- 143.244.210.106
- 143.244.209.90
Alternatively, find them in your **Data Source Connection Form.**
If you are not sure how to whitelist an IP address, check out our guide [here](/docs/connect/ip-whitelisting).
## How do i know which data center i'm on?
Your data center corresponds to your Holistics subdomain:
- Asia-Pacific (APAC): `https://secure.holistics.io`
- Europe (EU): `https://eu.holistics.io`
- United States (US): `https://us.holistics.io`
Alternatively, while in-app, you can click on the
help_outline icon to find information about your data center.
## How do i choose a data center for my account?
Upon registration at https://www.holistics.io/request-trial/, select your desired data center in the registration form.
If you wish to migrate your account, please read the following sections.
## How do i log in to a different data center?
When logging into Holistics, it is important to choose the correct data center.
## Migrating to another region
:::info Important!
Please note that we **do not** support:
- migrating between EU and US servers.
- migrating from US/EU server back to the APAC server.
:::
### What you need to know about the migration
If you already operate on one data center and need to move to another, please request your migration via our **Migration Request form** [here](https://www.jotform.com/form/220589454934466).
- The migration process typically takes around **6 hours - 12 hours**.
- During the migration, we will turn on [Maintenance Mode](/docs/admin/maintenance-mode) for your team. This means that your team, apart from users with **Admin** role, **cannot access** Holistics' features.
- In addition, all automated schedules (emails, Slack etc) will be paused (non-executing) during this period.
- **Your passwords will possibly be altered.**
- **For User Passwords:** Try logging in using your old passwords first. If you are unable to login, first check if you are on the correct region. If you are still unable to log in, generate new passwords by clicking on *Forgot your password*.
- **For Shareable Link Passwords:** Try logging in using your old passwords first. If the old passwords are invalid, you will need to configure them manually again. Please refer to [this document](/docs/delivery/shareable-links#protect-shareable-links-with-password) for more information on how to set up passwords for your shareable links.
- **For Shareable Link and Embedded Dashboard URLs:** After the migration to the new data centre, shareable links and embedded dashboards will also be hosted on the new domain. Old links will become invalid after your old Holistics account is deprecated. Please replace the links within 4 weeks to prevent disruption.
- Example: `https://secure.holistics.io/dashboards/XYZ` will become `https://us.holistics.io/dashboards/XYZ` after migration to the US Data Center or `https://eu.holistics.io/dashboards/XYZ` after migration to the EU Data Center.
Please take note that **your existing Holistics account will be safe**. We don’t foresee any risk that your existing models, datasets, and reports will be modified or removed. Therefore, if any unexpected scenario arises, **we can always retry the migration**.
### What you need to prepare for the migration
If you are using [Direct Connection](/docs/connect/connect-direct) method to connect to Holistics, [IP Whitelisting](/docs/connect/ip-whitelisting) for our EU (or US) application server is needed **before we start the migration**.
### What you need to do after the migration
If you are connecting to Holistics by [Reverse Tunnel](/docs/connect/connect-tunnel/) method, after Holistics **has completed the migration**, you will need to follow these steps to set up a new tunnel connection in your new tenant:
- **Step 1: Log in to your new US/ EU tenant**
- US login link: [https://us.holistics.io](https://us.holistics.io/)
- EU login link: [https://eu.holistics.io](https://eu.holistics.io/)
If you cannot log in to Holistics, you may need to click *Forgot your password?* and reset your password.
- **Step 2: Create a new data source using Reverse Tunnel, connecting to your database**
After the migration, your old data sources remain in your new account. However, they are now unusable and you need to re-create them:
- Go to **Organization Settings > Data Sources**
- Choose **+ New Data Source** > Create a new Data Source using Reverse Tunnel. Fill in your data source information.
Refer to [Tunnel Connection](/docs/connect/connect-tunnel/) document for more information on how to setup a tunnel connection.
- Repeat for all of your APAC data sources.
**Step 3: Contact Holistics when you have done re-connecting all data sources**
If you have created multiple data sources, kindly prepare a list which lists the old connections and their corresponding new names. This would help us know which pairs of data sources to be swapped.
```markdown
- Old Data Source name 1 - New Data Source name 1
- Old Data Source name 2 - New Data Source name 2
...
```
After you have confirmed that new connections have been successfully created, we will swap configurations between your old (existing, unusable connections) and newly-created connections.
When the swapping is complete, you can delete your old connections and the migration is finished.
### Old tenant's retention period
After the migration, all users will retain access to their old Holistics workspace for **4 weeks**. This is a precautionary measure to help your team confirm the migration is done correctly.
After 4 weeks, the old workspace will be deprecated automatically.
### Best practices for a successful migration
**✅ Read the migration doc carefully and prepare the necessary resources needed for the migration.**
It is advisable to consider the pros and cons of the migration scrupulously. Please do not hesitate to reach out to Holistics agents if you need more professional consultation before coming to a decision.
**✅ Check carefully for anomalies after the migration and inform Holistics ASAP so that we can retry the migration.**
Your old tenant, along with all your information, will not be modified or removed for 4 weeks. Therefore, if errors arise from the current migration, Holistics can safely retry it.
**✅ Inform your users to log in to the new region after the migration is confirmed to be successful.**
When you have confirmed that there are no anomalies with the new account, inform your users to select the new region when logging in.
**❌ Rush to create new definitions (reports, data models, etc.) without confirming the migration is accurate.**
In the case that a migration needs to be rerun, you will risk losing all newly created definitions.
For more information, please contact us at support@holistics.io, and we will assist you on a case-by-case basis.
## FAQs
### Does migrating to another data center help with my report performance?
There are many factors that affect report performance such as query complexity, the size of processing data, the network latency to Holistics data centers, etc. As such, **migrating to a data center in close proximity to your data warehouse would certainly decrease the response time, but we would not be able to predict how significantly it would improve your performance**.
For example, let's say:
* Your query takes 10 seconds to run on your database.
* Data transfer from your database to APAC region takes 2 seconds.
* Data transfer from your database to EU region takes 1 second.
-> Migrating to the EU data center would reduce the total loading time from 12 seconds to 11 seconds, which is an 8.3% improvement. However, if your query takes 100 seconds, the migration would only give a 0.83% improvement.
Therefore, we advise that if you have performance problems, start by [diagnosing the biggest contributors to slow loading time](/docs/performance/troubleshooting). **Migration decisions for reporting performance purposes should only be made after you have confirmed that network latency is the main cause of slow reports**.
---
## Data retention period
This page describes Holistics data retention policy.
## For current customers
### Monitoring logs
Refer to this table below for Holistics [monitoring log](/docs/monitoring) retention period.
| Dashboard Type | Retention Period |
| ----------------------------------------------------- | ------------------------- |
| [Activity Logs](/docs/monitoring/activity-logs) | 6 months |
| [Jobs Monitoring](/docs/monitoring/job-monitoring) | 3 months |
| [Usage Monitoring](/docs/monitoring/usage-monitoring) | 6 months |
| Non-git Object Versions | 3 months |
### AI conversations
Conversations with Holistics [AI-powered features](/docs/ai/data-access-and-policy) are encrypted at rest and retained for **30 days**, after which they expire and are deleted.
## For deactivated customers
After a customer unsubscribe, or when their trial is expired, Holistics will retain their data for **180 days**. After that, the data would be removed from the system.
---
## Data security
## Is Holistics GDPR-compliant?
Yes we are. Please see our [GDPR page](/legal/gdpr) for more information.
## Is Holistics SOC2-compliant?
Yes we are. Please see our [SOC2 Compliance page](/docs/security-compliance/soc2) for more information.
## Where are Holistics' servers located?
All of our servers are hosted with reputable data center providers:
- Our Asia-Pacific servers are located in Singapore
- Our Europe servers are located in Frankfurt, Germany
- Our US servers are located in San Francisco
## Does Holistics store my data?
Holistics **does not** **store** your raw data in our servers. This means that your data sits securely within your system at all times. You retain full ownership and control over your data.
When a user runs a report or explores a dataset, Holistics will generate and send an SQL query to your database for processing. Once the query completes, Holistics will display the results on your browser.
Holistics **only stores** a few things:
- **Metadata**: the definitions and settings of your reports, dashboards, models... etc. For examples: report's query, description, chart type, delivery schedules...
- **Cache**: Holistics gives you the option to set a cache for your reports, dashboards and filters. This speeds up access to your data and also protects your database against repeated queries. Cached data will expire after a determined period of time. No cache data will ever be stored forever on Holistics servers.
### What does your cache store?
The cache layer only stores the **query results** (not the raw data of your database). For further technical details of Holistics's cache system, please refer to our docs about [Holistics Reporting Mechanism](/docs/performance/data-caching).
### When exactly does your cache store the data, and for how long?
Our cache server stores your query results in two instances:
1. **Initial Retrieval**: When we first fetch the query result from your database, you can set the data caching duration in the Settings tab of the report.
2. **Report Export**: When someone exports a report to Excel/CSV, we generate and store the file on our AWS S3 server. The encrypted files are automatically removed after 24 hours.
For personalized storage on your S3 cloud, available in the Enterprise plan, please request this through your account admin(s) via an in-app support ticket.
### When i persist my SQL data model into a physical table, where is the table stored?
As outlined in the documentation on [Query Model Persistence](/docs/query-models#model-persistence), optimizing the load time of your SQL model involves transforming the result set of the SQL query into a physical table **within your database**.
Similar to reports, dashboards, filters, and other elements, Holistics only stores the metadata of the model (including SQL, model description, custom field formulas, etc.).
## My database is behind a private firewall. how do i give access to Holistics?
As mentioned in [Connect Database](/docs/connect/connect.md), to securely open your DB for Holistics to access, there are two ways:
- **IP Whitelist**: You can add Holistics' IP addresses to your whitelist so that Holistics can connect to your DB. For more information, please refer to [Direct Connection](/docs/connect/connect-direct.md) section.
- **SSH Tunnel**: You can setup a secure SSH tunnel from your DB to Holistics Network, so that all traffic will go through this channel. For more information, please refer to [Setup Reverse SSH Tunnels](/docs/connect/connect-tunnel/) section.
## Since our database credentials are stored in Holistics's system, how do you protect them?{#credentials-protection}
We apply AES encryption before storing your credentials in our database. The credentials are then decrypted on the fly whenever we make a connection to your DB server, and the raw credentials are never persisted anywhere. The encryption key resides in a server separated from the DB server.
Our DB server is under a private VPC network and is only accessible by our app servers.
## How do i track what data/reports my team has accessed?
Please refer to [Monitoring Dashboard](/docs/monitoring) for more information.
---
## Security and Legal Compliance
Keeping your data safe and being clear about how we handle it is core to how we run Holistics. This section gathers the documents that cover our security practices and the legal terms behind your account.
## Security
How we protect your data, where it lives, and the certifications that back it up.
Where your data is stored, how it's encrypted, and how we keep it isolated.
We're SOC 2 Type II compliant, with continuous monitoring. Request our report here.
Found a vulnerability? Here's how to report it and what to expect from us.
## Legal and privacy
The policies and agreements that govern your use of Holistics and how we process your data.
Our commitment to the EU General Data Protection Regulation.
The terms covering how we process personal data on your behalf.
What data we collect, how we use it, and the choices you have.
The agreement that governs your use of the Holistics platform.
---
## Restricted access by IP addresses (IP whitelisting)
:::caution Important
It is important to note that **your Shareable Links and Embedded Dashboards are publicly accessible** even if you have restricted access by IP addresses.
:::
This feature allows you to restrict access to Holistics application to only some IP addresses you define.
Go to [Settings](https://secure.holistics.io/manage/settings "Settings"), scroll down to **IP Whitelisting** and turn on `Restrict Access via IPs`. Then add the IP addresses you want to whitelist and `Save`.
Now, only the IP addresses you list here can access Holistics or using API. IP addresses out of this list will get `You do not have permission to visit this page`.
:::note
To avoid blocking yourself, the list must include your current IP.
In case you need technical support from Holistics support team, please add Holistics' Office IP address: **14.161.47.182**.
:::
---
## Responsible disclosure
# Software Responsible Disclosure/Bug Bounty Policy
_Updated: 26 Aug 2020_
Data security is a top priority for Holistics, and we believe that working with skilled security researchers can identify weaknesses in any technology. If you believe you’ve found a security vulnerability in Holistics Software’s service, please notify us; we will work with you to resolve the issue promptly.
## Disclosure/bug bounty policy
* If you believe you’ve discovered a potential vulnerability, please let us know by [submitting a report](https://form.jotform.com/220754023813044). We will acknowledge your submission within one week.
* Provide us with a reasonable amount of time to resolve the issue before disclosing it to the public or a third party. We aim to resolve critical issues within five business days of disclosure.
* Make a good faith effort to avoid violating privacy, destroying data, or interrupting or degrading the Holistics Software service. Please only interact with accounts you own or for which you have explicit permission from the account holder.
* Depending on the severity of the vulnerability reported, we will consider paying you to compensate for your effort.
## Non qualifying vulnerabilities
* Login or Forgot Password page brute force and account lockout not enforced.
* Brute-force, / Rate-limiting, / Velocity throttling, and other denial of service based issues.
* Username or email address enumeration
* Email bombing
* Content spoofing/Text injection
* XSS vulnerabilities on sandbox domains, XSS (or a behavior) where you can only attack yourself (e.g. "Self XSS").
* Social engineering
* Clickjacking and issues only exploitable through clickjacking, unless accompanied by a real-world attack scenario and meaningful impact.
* Login/Logout/Unauthenticated CSRF
* Missing cookie flags on non sensitive cookies
* Missing security headers which do not lead directly to a vulnerability
* Vulnerabilities affecting users of outdated or unsupported browsers or platforms
* Attacks requiring physical access to a user device
* Low impact descriptive error pages and information disclosures without any sensitive information
* Invalid or missing SPF/DMARC records
* Password and account policies, such as reset link expiration or password complexity
* Bypassing pricing/paid features restrictions
* HTTPS Mixed Content messages
* Version number information disclosure
## Exclusions
While researching, we’d like you to refrain from:
* Distributed Denial of Service (DDoS)
* Spamming
* Social engineering or phishing of Holistics Software employees or contractors
* Any attacks against Holistics Software’s physical property or data centers
Thank you for helping to keep Holistics Software and our users safe!
## Changes
We may revise these guidelines from time to time. The most current version of the guidelines will be available [here](responsible-disclosure.md).
## Contact
* Please submit the [vulnerability report](https://form.jotform.com/220754023813044). We will acknowledge your submission within one week.
* Holistics Software is always open to feedback, questions, and suggestions. If you would like to talk to us, please feel free to email us at support@holistics.io.
---
## SOC2 compliance
Holistics is SOC2 Type 2 compliant. We’ve achieved our SOC 2 Report by partnering with Prescient Assurance, an independent auditor, and Vanta - the leading automated security platform - for continuous SOC2 compliance monitoring.
You can read the [official blog post](https://www.holistics.io/blog/holistics-is-soc2-compliant/) or [community post](https://community.holistics.io/t/its-official-holistics-is-now-soc2-type-ii-compliant/1328) for more information. Please fill out [this form](https://go.holistics.io/soc2report) to get access to our SOC2 report.
---
## Share your data exploration
While exploring a dataset, you may want to share interesting tidbits that you found with others in your organization. There are two ways to do so:
#### Sharing your exploration state
For every change you make when exploring a dataset, Holistics will generate a unique URL, and you can give the link to your colleagues to continue with your exploration, provided that they have permission to the underlying datasets and models:
#### Sharing a static snapshot
On the other hand, if you only want to share the result of your exploration, you can export a snapshot of your current visualization and the underlying data:
For example, exploring both chart and data to an Excel file:
---
## Show all dimension values (including empty)
## Introduction
Missing data in reports is a common issue. Sometimes you need to see all possible values in your dimensions, even when there's no associated data. For example:
- Show all users, including those who made no orders yet
- Display all dates in a time series, even days with no sales
This guide covers two main scenarios and how to handle each one.
## Use Case 1: Show All Users (Even with Empty Orders)
### The Problem
You want a table showing order counts by user, but users who haven't placed any orders are missing from the results.

### Why This Happens
Let's say we have a dataset ecommerce like this
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
models: [orders, users, date_dim]
relationships: [
relationship(orders.user_id > users.id, true),
relationship(orders.created_date > date_dim.date_key)
]
}
```
When Holistics explores data with metrics from the many-side of a relationship (like `orders`), it performs a LEFT JOIN from many to one. This means:
- Start from `orders` (many side) → LEFT JOIN to `users` (one side)
- Only users who have orders will appear in results
- Users with no orders are excluded because they don't exist in the starting table
For more details, visit our doc about [**How JOINS are constructed**](/docs/datasets/dataset-relationships#how-joins-are-constructed)
### High-Level Approach
Force Holistics to include all users by adding a metric from the users model (for e.g., `total users`). When a model has a metric, Holistics ensures all its rows appear in results.
```aml
explore {
dimensions {
users.full_name
}
measures {
count_orders: count(orders.id), // Your original metric
total_users: count(users.id) // Forces all users to appear
}
}
```
### Hide the Helper Metric
Since you don't want to show "Total Users" in your report, simply hide the column:
Click the column header → Hide Column. Your table now shows all users (including those with no orders) but keeps the helper metric hidden.
For more details, see [**Show/hide columns**](/docs/charts/table#show--hide-columns)
### What if you want to apply filters?
Sometimes you'll need to filter your data - for instance, you might only want to count delivered orders (where `orders.status == 'delivered'`).
Here's the catch: when you apply this filter, users without any orders disappear from your report again. Why? The filter travels from the `orders` model to the `users` model. Since some users don't have any delivered orders (or any orders at all), they get filtered out completely.
```aml
explore {
dimensions {
users.full_name
}
measures {
count_orders: count(orders.id), // Your original metric
total_users: count(users.id) // Forces all users to appear
}
filters {
//highlight-next-line
orders.status == "delivered"
}
}
```
The solution is to modify your helper metric so it stays isolated at the `users` level and isn't affected by filters on other models like `orders`. You do this by adding `keep_grains(users)` to your metric definition.
```aml
explore {
dimensions {
users.full_name
}
measures {
count_orders: count(orders.id),
//highlight-next-line
total_users: count(users.id) | keep_grains(users)
}
filters {
orders.status == "delivered"
}
}
```
For more details, see the [`keep_grains`](/reference/aql/keep) documentation.
By keeping the metric at the `users` grain, filters on other models won't affect which users appear in your results. Users with no delivered orders will still show up with a count of zero.
This same principle applies to Use Case 2 below when working with date dimensions and filtered data.
## Use Case 2: Running Totals Across All Dates
### The Problem
You want to create a running total chart by date, but missing dates create awkward jumps in your line chart instead of smooth continuity.

### High-Level Approach
Same principle as Use Case 1, but applied to dates: add a metric from the date dimension to force all dates to appear.
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
...
metrics running_total_orders {
label: 'Running Total Orders'
type: 'number'
definition: @aql window_sum(orders.total_orders, order: 'x_axis') ;;
}
metrics total_dates {
label: 'Total Dates'
type: 'number'
definition: @aql count(date_dim.date_key);;
}
}
```
```aml
explore {
dimensions {
date_dim.date_key
}
measures {
running_total_orders: running_total_orders,
total_dates: total_dates // Forces all dates to appear
}
}
```
### Hide the Helper Metric
Since charts work differently than tables, you have several options to hide the helper metric:
#### Option 1: Use tooltips
Add the helper metric to tooltips instead of the main visualization:
This keeps your chart clean while ensuring all dates appear.
#### Option 2: The mathematical trick
Combine the helper with your main metric to make it invisible:
```aml
measure running_total_all_dates {
type: "number"
definition: @aql
running_total(count(orders.id)) + count(date_dim.date_key) - count(date_dim.date_key)
;;
}
```
This adds and subtracts the same value, forcing inclusion without changing results.
#### Option 3: Use conditions
Add the helper metric to visualization conditions instead of displaying it:
Set the condition: `count(date_dim.date_key) > -1`
Since count is always 0 or positive, this condition is always true and forces all dates to appear without showing the metric.
## When NOT to Use This
- **Large dimension tables**: Showing all 1 million users might affect the performance. Consider filtering first (e.g., users from last 3 months)
- **Irrelevant dimensions**: Not every report needs every possible value
## Related Concepts
- [Understanding Dataset Relationships](/docs/datasets/dataset-relationships)
- [Customizing Table Visualizations](/docs/charts/table)
- [Working with Tooltips](/docs/charts/customizing-chart-tooltip)
---
## Show Rows with No Data
By default, Holistics will not present items with no data when exploring the dataset.
For example, you have 2 models `users` and `orders`, the relationship between them is 1 to many. If you want to understand how many orders each user has, you will add in 2 fields: `users.name` and `count(orders.id)`. However, we will exclude users that have not made any purchases.
The **Show rows with no data** feature let you include data rows and columns that don't contain measure data (blank measure values).
## How to set up?
In order to include items with no data, you can toggle on the option `Show rows with no data`
under `STYLE` > `Others` in our Visualization Settings. Please refer to the video below:
## Related topics
- [Show rows with no data when applying filter](/docs/filters/show-row-no-data-applying-filter)
---
## Storage Mode
When loading data from another source to or materializing a SQL Model in your data warehouse, there are 4 types of storage mode including:
- [Full](storage-mode#full)
- [Append](storage-mode#append)
- [Incremental](storage-mode#incremental)
- [Upsert](storage-mode#upsert)
## Configure Storage Settings
At this moment, Storage Settings are enable for Import Model or when you want to materialize/store a SQL model in your Data Warehouse.
For more details, please refer to [Storage Settings](storage-settings)
## Full
Whenever the storage process is run, the whole result set of the SQL will be used to replace the previously created table. This should be used if your data is small, the records change regularly and you do not need to retain the history of your data.
- **Mechanism**: We will drop the existing table in your destination and create a new table to load data from source to destination
- **Pros:**
- Tables are fast to query
- Easy to setup and does not require additional steps like incremental or upsert
- For Data Import: Work with all type of source (flat file, app data, SQL database)
- **Cons:**
- Tables can take a long time to rebuild, especially for complex transformations
- Quite costly if your data is large (especially if your destination is BigQuery)
## Append
This should be used if you want to retain the history of your data. When the storage is run in Append Mode, all records from the source table at run time will be appended to the destination table, and old records are left untouched.
- **Mechanism**: select all record from source and insert them directly to existing table in destination.
- **Pros**: Good for cases when you want to analyze historical data that changes over time
- **Cons**: May lead to duplicated data if your historical records don't change
## Incremental
This should be used if your data is large, but past records do not change. New records from the model's query will be appended to the destination table. For this to work, you need to specify an increment column so Holistics can decide on the correct data to extract.
- **Mechanism**: We will rely on the incremental columns (which you have specified in advance) to only get the new records and store in a list (called `max_value_destination`)
- **Step 1**: get the **max value** of **incremental column** from the **destination** table (let's call it `max_value_destination`)
```sql
SELECT MAX(incremental_col) FROM des_table
```
- **Step 2**: Only **retrieve records** that have the value in the **incremental column** from **source** table **greater** than that from **destination** table
```sql
SELECT source_data
FROM source_table
WHERE incremental_col > max_value_destination
```
- **Step 3**: **Insert all new records** from the `max_value_destination` of step 2 to **destination**
- **Pros:** You can significantly reduce the build time by just transforming/importing new records
- **Cons:**
- Incremental requires extra configuration (select incremental column)
- For Data Import: only work with SQL databases
- **Advice (when to use):**
- Source data tables have millions, or even billions, of rows.
- The transformations on the source data are computationally expensive (that is, take a long time to execute), for example, complex Regex functions, or UDFs are being used to transform data.
- Only use this if your past data doesn't change. If there are changes in past record, you should refer to [Upsert](storage-mode#upsert).
## Upsert
In case your data is large and past records change, this should be used. You will need to specify a Primary Key and an increment column.
- If there are new records from Source table, those will be appended to the Destination table.
- If the existing records from the Source table are updated, we will replace the related rows in Destination with the updated ones from Source.
- **Mechanism**:
- **Step 1**: select **max value** of **incremental column** from the **destination** table (usually `updated_at` column) and let's call it `max_value_destination`)
```sql
SELECT MAX(incremental_col) FROM des_table
```
- **Step 2**: Only **retrieve records** that have the value in the **incremental column** from **source** table **greater** than that from **destination** table
```sql
SELECT primary_key, source_data
FROM source_table
WHERE incremental_col_source > max_value_destination
```
- **Step 3**: Referring to the `primary_key` of records in the `max_value_destination` in step 2, **delete related rows** in **destination**
- **Step 4**: **Insert all new records** from the list of step 2 to **destination**
- **Pros:** You can significantly reduce the build time by just transforming/importing new and updated records
- **Cons:**
- Upsert requires extra configuration (select incremental column)
- For Data Import: only work with SQL databases
- **Advice (when to use):**
- Source data tables have millions, or even billions, of rows.
- The transformations on the source data are computationally expensive (that is, take a long time to execute), for example, complex Regex functions, or UDFs are being used to transform data.
- When your past data in source table changes through time and you want to update them in destination table.
---
## Model Storage Settings
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Persist Query Model](/docs/query-models.md#model-persistence)
- [Persist Import Model](import-models)
:::
## Introduction
:::caution Your Holistics DB credentials must have write acccess to enable Storage Settings
Because Holistics will write data to your data warehouse with this setting, ensure that your DB credentials has **write access** before enabling this mode.
:::
In both Holistics's [Import Models](/docs/import-models.md) and [Query Models](/docs/query-models.md), Holistics writes data to your data warehouse to either make it available for querying, or to improve query performance. There are four writing modes supported in Holistics:
- [**Full mode:**](storage-settings.md#full-mode) The destination table will be replaced completely by a new result set.
- [**Append mode:**](storage-settings.md#append-mode) A full snapshot of the source data will be appeneded to the destination table, and old record are left intact. This mode is available for Import Models only.
- [**Incremental mode:**](storage-settings.md#incremental-mode) New records will be appended to the destination table.
- [**Upsert mode:**](storage-settings.md#upsert-mode) A combination of UPDATE and INSERT. Records with changes will be updated, and new records will be inserted into the destination table.
These modes behave a bit differently between Import Models and Transforms Models, and we will go into the details below.
## Full mode
You may want to use Full mode if your data is small, the records change regularly, and you do not need to retain the history of your data.
> **Note:** If you are importing a large table (like events or log tables), you should avoid Full mode and use Incremental mode instead.
**Mechanism:**
This mode works similarly in both Import and Query Models. Basically, Holistics will drop the old table in your destination if (if it already exists), and replace it with new data.
- In **Import Models**, this means the destination table will be replaced by the most recent snapshot of your source data.
- In **Query Model**, the destination table will be replaced by the new result set produced by the model's SQL.
## Append mode
You may want to use Append mode if your source records are updated directly, and you want to retain a log of those changes. In this mode, a **full copy** of your source table will be appended to the destination table.
> **Note:** The Append mode is only availabe for **Import Models**.
**Mechanism**:
Holistics will get all records from the source, and insert them to the existing destination table. Old records are left untouched.
- **Pros**: Can retain historical changes of your data.
- **Cons**: Your destination table will become bloated quickly if the run frequency is high.
## Incremental mode
This mode behaves differently between Import and Query Models: it can reduce the amount of data scanned in Import Models, but cannot do so in Query Models.
### In Import Models
This should be used if your source table is large, and **past records do not change**. In this mode, only new records from the model's query will be appended to the destination table.
> **Note:** This mode is only available for SQL data sources.
**Mechanism**:
To use Incremental mode, you only need to specify an **increment column**, and Holistics will use that column to determine the data range to be queried from the source. Depending on your case, this can be a `created_at` or an `updated_at` column.
How the import will work:
- **Step 1**: get the **current max value** of the **increment column** from the destination table (let's call it `max_value_of_destination`)
```sql
SELECT MAX(incremental_column) FROM dest_table
```
- **Step 2**: Only retrieve records whose value of the **increment column** is greater than the current max value above:
```sql
SELECT source_data
FROM source_table
WHERE incremental_col > max_value_of_destination
```
- **Step 3**: Insert all new records retrieved in Step 2 to the destination table.
This way, the import run time is reduced significantly because Holistics only needs to scan a section of your data, not the whole source table.
### In Query Models
At the moment, Holistics Query Model **cannot dynamically limit the range of data** it will query. The Incremental Mode can only determine which records to be inserted into the destination table.
How it works:
- **Step 1**: get the max value of the **increment column** from the destination table (let's call it `max_value_of_destination`):
```sql
SELECT MAX(incremental_column) FROM dest_table
```
- **Step 2**: Run the model's SQL as-is to produce a result set. For example, we have an `orders_aggregation` model that counts the number of orders created daily:
```sql
SELECT
created_date
, count(*) as orders_count
FROM orders
group by 1
```
- **Step 3**: Holistics uses `max_value_of_destination` to filter the result set produced in Step 2, and insert the filtered records into the destination table.
Basically, the process will look something like:
```sql
INSERT INTO dest_schema.orders_aggregation
WITH base as (
SELECT
created_date
, count(*) as orders_count
FROM orders
group by 1
)
SELECT * FROM base
WHERE created_date > {{max_value_of_destination}} -- Filter is outside of the CTE
```
This means **the whole source table is still scanned**, and the process can be potentially costly.
## Upsert mode
This mode also behaves differently between Import and Query Models.
### In Import Models
In case your source table is large and past records are updated, this should be used.
- If there are new records, those will be appended to the destination table.
- If the existing records from the source table are updated, we will update the corresponding records in the destination table.
> **Note:** This mode is only available for SQL data sources.
**Mechanism**:
To use this mode, you will need to specify both the **Primary Key** and the **increment column**. Normally they are the the `id` and `updated_at` columns.
- **Step 1**: Get the max value of hte **increment column** from the destination table (let's call it `max_value_of_destination`')
```sql
SELECT MAX(incremental_col) FROM des_table
```
- **Step 2**: Retrieve the source records with increment column's value greater than `max_value_of_destination`
```sql
SELECT primary_key, source_data
FROM source_table
WHERE incremental_col_source > max_value_of_destination
```
- **Step 3**: Delete records in the destination table with `primary_key` existing in the result set of Step 2.
- **Step 4**: Insert all new records produced in Step 2 to the destination table.
Similar to Incremental mode, this can also reduce your data import run time.
### In Query Models
Upsert mode in Query Models shares the limitation with the Incremental mode: the `WHERE` filter is not applied within the initial query, but **applied to the result set produced by the query.**
Therefore, Upsert mode in Query Models is still potentially costly.
## FAQs
### I migrated the destination tables in my data warehouse. The persisted models that use these tables encounter errors when I use them in my queries.
You will need to **reset the storage settings** of persisted models if you have made changes to the schema of the destination table in your data warehouse.
For example, your **Query/Import Models**' [destination tables](/docs/query-models.md#model-persistence) reside in *schema A* of your data warehouse. You then relocate these tables from *schema A* to *schema B* in your data warehouse. When this happens, Holistics **will not able to to detect the new schema** of the destination tables. Subsequently, all queries that use these persisted models will fail as they would use the old schema.
To resolve this issue, you will need to change the schema of your persisted models:
- To reset the schema of **Query Models**, you will need to reset the storage settings and [create a new Storage Setting](query-models.md#model-persistence) with the new schema.
- To reset the schema of **Import Models**, go to [**Advanced Settings**](import-models#advanced-settings) > **Destination Settings** and edit your schema.
### What if I still want to do incremental transform?
As stated in [Query Models docs](/docs/query-models.md), we do not intend to make this feature a full-fledged ETL solution. Normally for more advanced data transformation use cases, we recommend our customers to checkout dedicated transformation tools like [dbt](https://www.getdbt.com/) or [Dataform](https://cloud.google.com/dataform).
---
## Table Model
## Introduction
Table Model is a data model created directly from an existing database table. This is like an abstract representation of your underlying table which you can easily manipulate, annotate, add custom dimensions and measures. With Table Models, you can extend on the original data without affecting the physical table.
The goal of creating table models is to make an existing SQL table available in the Holistics modeling layer.
## Create a Table Model
To create a new table model, go to the Development, click on the `+` symbol next to a folder, and select **Add Table Model**. A new screen will appear, and from here you can choose the table to create a model from.
After selecting one table (or several tables), new `.model.aml` files representing the model will be created.
:::info Note
There is **no limit** on how many Table Models you can create on your account.
However, you can only create **at most 100 Table Models in one selection.**
:::
### Table Model Syntax
Below is a sample of how the Table Model syntax will look like. All of these codes will be generated when you create the model using the graphical UI, but you can also create the table model manually by writing from scratch:
```aml title="model_name.model.aml"
Model model_name {
type: 'table'
label: "Model Label"
description: ""
owner: 'user@your-domain.com'
data_source_name: 'data_source_name'
table_name: '"schema"."table_name"'
dimension dimension_name_1 {
// dimension 1 definition
label: 'Dimension Label 1'
type: 'text' | 'number' | 'date' | 'datetime' | 'truefalse'
description: 'Field Description'
hidden: true | false
definition: @sql {{#SOURCE.column_name}};;
}
dimension dimension_name_2 {
// dimension 2 definition
}
measure measure_name_1 {
// measure 1 definition
label: 'Measure Name 1'
type: 'number'
definition: @aql count(model_name.dimension_name) ;;
}
}
```
:::info
Please refer to [AML Table Model Reference](/reference/aml/table-model) to learn more about all available parameters and their example usage.
:::
## Sync schema changes from database table
If there are any changes in the schema of the underlying table (columns are deleted, renamed, or added...), you can select **Refresh Model** to reflect the changes.
---
## Top/Bottom N Filters
> Top N Filter is available in Holistics since 26th January 2021.
Top N Filter is a new option in Visualization Settings' Condition that allows you to answer questions like: "What are the top 3 products with the highest revenue?"
In this guide, I will walk you through the steps to use Top/Bottom N Filter.
## Context
You have a data model `order_items` that contain all the transactions of the business.
```sql
Table order_items {
order_item_id
order_id
order_created_at
product_created_at
product_name
product_id
quantity
product_price
total_item_value -- item_value = order_item.quantity * product.price
}
```
From that model, you have built a Product-overview table that contains `Product Id`, `Product Created Date`, `Product Name`, and `Product Price`. You can see we have many products here.
Now, the question is: We want to filter the table to only display **the top 3 products** with the highest **Revenue** (**sum of total item value)**. The expected result looks like below. You can see now we only have 3 products: Body Treatments, Body Cleanser, and Body Scrub, because they have the highest Revenue.
## Solution in Holistics
- Step 1: Explore/edit the current table
- Step 2: Drag/select the field `Product name` into Conditions. Select top N filter from the drop-down.
- Step 3: Since we want to find the top 3 products, we input 3 in **N value**.
- Step 4: Since we want to rank the products by their sum of total item value, we select the field `Total item value` in the **By** section.
By default, Holistics will select **Sum** as the aggregate function. You can change that by clicking on `Total item value` and select from the dropdown list.
Click **Apply**, then **Get Results**.
Note: The field `Total Item Value` does not necessarily need to be used in the table for you to use it in top/bottom N filter.
- The table is filtered to only show the top 3 products by the sum of total item value. We're seeing 3 different product names here: Body Treatments, Body Cleanser, and Body Scrub.
- To double-check the result, you might want to create another table that only shows the product name and sum of the total item value, then sort the sum of the total item value column by descending.
- The top 3 results are: Body Treatments, Body Scrub, and Body Cleanser, which match the 3 product names we see in our original table. Great!
## How it works
### Use dense rank to return the top N records
**Top/bottom N** items, based on a value, return the list of items with the values in the top N.
Behind the scenes, Holistics use **dense rank** to return the top N records from each group with no gaps in the sequential rank numbering of rows in each windowing partition. Therefore, there might be more than N items because some might share the same highest values.
*For example,* Top 10 products by `Gross Number Of Orders Items sold` return 12 products.
This because products that have the same `Gross Number Of Orders Items sold` will have the same rank. Top 10 filter will show items from #1 to #10, the 12th item is #9, so it also appears in the report.
### Top N Filter will be applied after all other filters have been applied.
Before you begin filtering Top/Bottom N value, it's important to understand the order in which Holistics executes filters in your report.
Top N Filter is one type of Widget Filters, and will be applied after below filters:
- The dashboard filter
- All other widget filters
For example, let's say we have 2 filters:
- Product Created Date is between 1st October 2019 and 1st October 2020.
- Top 3 Product name by sum of total item value
In this case, Holistics will find all the products that are created between 1st October 2019 and 1st October 2020, then find the top 3 products by the sum of total item value that occurs in that time range.
As you can see, the 3 products name are now different: Sony Smart TV, Body Cleanser and Sofa 1, which means the date filter is applied _before_ the top N filter.
If you apply a dashboard filter to a report that already has top N filter applied, the dashboard filter will still be applied first, then the top N filter will be applied to that report.
## Notes
1. Holistics does not support more than 1 top N Filter at the same time.
2. N value must be a positive integer.
---
## Trend Lines
## Add a trend line
Trend Lines reveal the overall direction of your data.
Trend Lines are supported in **Line, Column, Bar, Area, and Combination Charts**, and when **X-axis contains numerical values** (number, datetime, or date).
Adding a Trend Line in Holistics is very simple:
## Trend line types
Depends on what story you want to tell with your data, and the values of your data, you may want to use different trend line types in different cases.
### Linear
A linear trend line shows if your data is generally **increasing or decreasing at a steady rate.**
You can also stick to linear when you just want to show the overall increasing or decreasing direction of your data overtime.
### Logarithmic
This is useful when things are **growing or shrinking really fast at first, then slow down** and eventually flatten out. It works for both positive and negative values.
### Power
Think of a curved line that shows something **increasing faster and faster over time**. Imagine a car speeding up. This is useful for data that grows quickly at first and keeps accelerating. You cannot create a power trend line if your data contains zero or negative values.
### Exponential
Imagine a steep curved line, like rocket launching speed or user’s productivity when first using Holistics 😉 This is useful when things are **growing or shrinking incredibly fast at first, then the speed of change keeps increasing**. An exponential trend line won’t work if your data contains zero or negative values.
### Moving Average
A moving average trend line **smoothes out data fluctuations** to reveal a clearer pattern or trend.
**Tip**: for "Number of periods", we show the quick options of up to 3, but you can type in any number you want (e.g. 7 or 15).
**Note**: Moving Average is only available in Holistics 4.0.
## Displaying one or multiple trend lines
When a Legend is present in the chart, Holistics will, by default, display a separate trend line for each legend value, resulting in multiple trend lines. However, you have two toggles, **break down by legend** and **merge into one line**, to turn them into one single trend line that better suits your analytical needs.
Please note that both toggles are available only in AQL-enabled datasets.
### Option 1: Turn off the “break down by legend” toggle
- This toggle disables the effect of the Legend on the trend line. With the "Break down by legend" toggle turned off, the trend line will be the same as it would be in a chart without a legend.
- This option is suitable when you want to visualize the trend line of the total values, irrespective of the Legend.
- Here’s an example for what “break down by legend” toggle can do:
- Image 1 is a visualization with no Legend at all.
- Image 2 is a visualization with a Legend “Gender”, but the trend line’s toggle “break down by legend” is turned off.
- You’ll see that the purple trend lines in both images have the same values.
### Option 2: Turn on the “merge into one line” toggle
- This toggle calculates a single trend line based on all the segmented data points in the chart, similar to a scatter plot.
- This option is ideal if, after breaking down the total values, you wish to see the trend line among those segmented values.
- Here’s an example for the comparison of Option 1 and Option 2:
- Image 1 is the visualization with a Legend “Gender”, but the trend line’s toggle “break down by legend” is turned off.
- Image 2 is a visualization where we turn on both toggles “break down by legend” and “merge into one line” for the trend line.
- You’ll see that the orange trend line in the second image are generally lower than the those of the purple trend line in the first image. This is because the orange trend line is calculated based on every segmented data point of the blue "f" line and the green "m" line.
---
## Passing Filter Parameters via URL
## Introduction
In Holistics, you can filter a dashboard either by using the Filter pane directly on the dashboard or by adding string parameters to the dashboard URL.
Passing filter parameters via URL query strings is particularly useful in the following scenarios:
1. **Building a filtered dashboard without opening it**
2. **Dynamically populating pre-filtered dashboard links**
Imagine you have a **Performance Overview** dashboard with a **User ID** filter and need to create specific dashboard URLs for each user, this feature can simplify the process.
Instead of manually filtering the dashboard to get the link, you can quickly generate them as shown below:
User ID
User Email
Dashboard URL
1
john.brown@gmail.com
url?user_id=1
2
helen.tong@gmail.com
url?user_id=2
3
albert.einstein@gmail.com
url?user_id=3
...
...
...
## How It Works
You can add URL parameters to either the widget URL or the dashboard URL to pre-fill Interactive Controls such as [Filters](/docs/filters), [Period-over-Period Comparison](/docs/period-comparison), and [Date Drill](/docs/interactions/date-drills).
URL parameters can be used for both internal dashboards and [shareable links](/docs/delivery/shareable-links).
These parameters are also applied to hidden controls (filters that are not visible on the dashboard).
## URL Syntax
The basic syntax is straightforward: start with the report URL, add a question mark, and then append your filter syntax:
```aml
dashboard_url?&
```
- **Filtering multiple fields**: Use the “&” character to separate between different condition.
e.g. url?country=Vietnam&status=delivered
- **Filtering multiple values for a single field:** Use multiple instances of the same parameter name.
e.g. url?gender=male&gender=female
In which:
- **url**: The base URL of the dashboard or widget.
- **block_uname**: A unique identifier for the Filter, PoP Comparison, or Date Drill.
- **operator**: The operation to apply (e.g., equals, contains).
- **value**: The value passed to the interactive controls.
:::tip Note
- If you add an invalid value through URL parameters (e.g. add a string input to a Number filter), you’ll see an invalid indicator next to that interactive control.
- If you input multiple values for a single-select control, the last value will take effect.
:::
### Obtaining the Unique Identifier (block_uname)
To get the unique identifier (uname) of an interactive control:
1. Open the dashboard containing the control.
2. Click on the settings icon for the control.
3. In the settings modal, locate the “Filter Name” (or PoP Name, or Date-drill Name).

### Supported Operators
Currently, we support the following operators:
Field Type
Operators
Syntax
Example
Note
Filter
String, Boolean
is
=
url?country=Vietnam
url?is_male=false
String/Boolean query is case-sensitive
Number
equal to
=
url?score=8
url?score=8.0
Date, Datetime
matches
=
url?created_date=2020-01-24
url?created_time=2020-01-24 03:00
url?created_time=last week
Support both absolute and relative datetime
Period-over-Period Comparison
previous period
=
url?pop=prev 1 year
custom period
=
url?pop=2022-01-01 - 2022-02-01
none
=
url?pop=2022-01-01 - 2022-02-01
Date-drill
is
=
url?date_drill=Month
url?date_drill=year
Date-drill is case-INsensitive
## Other: Programmatically Generating Pre-filtered Dashboard URLs
If you generate filtered dashboards URLs programmatically, you can also use our API endpoint. The resulting URL will contain a single `_fstate` value, this parameter stores information about the applied controls and filters.
For detailed information, refer to the API documentation: [Build a Dashboard URL with preset filter states](/api/v2/reference/dashboards-build-url)

The advantage of using API to generate filtered dashboard URLs:
- If you have a lot of filter values, it won’t run the risk of exceeding the URL length limit that browsers impose.
- Using the API ensures better accuracy of logic of the filter conditions, since the condition logic is constructed using a JSON object.
---
## View and Edit as other users
As an Admin, you may need to *impersonate* other users on various occasions, for examples:
- When you want to check you have correctly implemented some permission controls.
- When you cannot reproduce a reporting problem that your end-user was facing.
The **Impersonating** feature can help you do just that. If you have an Admin account, when clicking on the top-right menu -> **View and Edit as...** you will see a list of users that you can impersonate:
Select a user, and you will be able to view Holistics reports/dashboards as if you are that user.
:::warning Note
This feature only works for Admin accounts. Other accounts when clicking on the **View and Edit as...** menu will not see any other users to be impersonated.
:::
---
## Conversational analytics (AI) in embedding
## Introduction
Ask AI brings **conversational analytics to your embedded application**. Your embed users can ask questions in natural language to explore data, get insights, and dig deeper into charts.
## Prerequisite
:::info
Ask AI is only available when you use [Embed Portal](./embed-portal/index.md) method.
:::
To use embeddable Ask AI , make sure you enabled:
- [AI](/docs/ai): Powers the natural language query capabilities.
- [Embed Portal](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54): The embedding framework to integrate Ask AI into your application.
## Enable Ask AI
To enable AI features for your embed users, set `settings.ai.enabled` parameters to true in your embed payload:
```javascript
const embed_payload = {
object_type: 'EmbedPortal',
object_name: 'your_portal_name',
embed_user_id: 'user_123',
embed_org_id: 'org_123',
//highlight-start
settings: {
ai: {
enabled: true, // Enable Ask AI feature
}
}
//highlight-end
}
```
## Configure data access & permissions
Ask AI can only query and return data the current embed user is permitted to see. It automatically enforces your Embed Portal's data permissions.
### Dataset access
Ask AI can query only [datasets included in your Embed Portal](/embedded/embed-portal/#choosing-what-to-embed). If a dataset isn't added to the portal, Ask AI won't be able to use it.
```aml title="sales_portal.embed.aml"
EmbedPortal sales_portal {
//highlight-start
objects: [
sales_dashboard,
ecom_dataset, // Ask AI can query this dataset
marketing_dataset, // Ask AI can query this dataset
]
//highlight-end
}
```
:::warning
Include at least one dataset in your Embed Portal, otherwise Ask AI won't be able to answer questions.
:::
### Row-level permissions (RLP)
Ask AI runs every query as the current embed user, so [Row-level Permissions](/embedded/embed-portal/permission-settings) are applied automatically. Users only see rows they're permitted to access.
## Set Ask AI as the default page (optional)
You can configure the embed portal to open directly to the Ask AI interface. There are two ways to do this:
### Option 1: Using embed definition
Set the `initial_object` setting in your `.embed.aml` file:
```aml title="sales_portal.embed.aml"
EmbedPortal sales_portal {
objects: [
sales_overview,
revenue_trends,
],
//highlight-start
initial_object: 'ai' // Open Ask AI as the default page
//highlight-end
}
```
### Option 2: Using URL path
Append `/ai` to your embed URL:
```
https://{region_domain}.holistics.io/embed/{embed_key_id}/ai?_token={generated_token}
```
Replace `{region_domain}` with `secure`, `us`, or `eu`. If you use a custom domain, replace the full `{region_domain}.holistics.io` host with your custom domain.
This option overrides the initial object defined in your embed definition, allowing you to dynamically control which page users land on.
For more URL customization options, see the [Parameters Reference](/embedded/embed-portal/parameters-reference#url-parameters-not-signed).
## White-label Ask AI
You can customize the Ask AI interface to match your brand by setting a custom icon, assistant name, welcome message, and prompt placeholders. See [White-label Ask AI](./white-label-ask-ai.md) for the full reference.
## Learn more
For more information about the underlying AI capabilities, see:
- [Holistics AI Overview](/docs/ai)
- [Ask AI in Natural Language](/docs/ai/capabilities)
---
## Dashboard Customization & Styling
## Introduction
To provide a seamless user experience, **your embedded analytics should be a native part of your application**. Holistics provides several levels of customization to ensure your dashboards align with your design system and user experience:
- **[Brand styling](#brand-styling-themes--custom-css):** Align colors, fonts, spacing, and borders with your host application using Themes and Custom CSS.
- **[Visualization styling](#visualization-styling):** Fine-tune how individual charts, tables, and KPIs look to highlight what matters most to your users.
- **[Custom visualizations](#build-custom-visualizations):** Build domain-specific visuals like Gantt charts and Sankey diagrams using Custom Charts and Dynamic Content Blocks.
- **[Add-on customizations](#add-on-customizations):** Fine-tune how dashboards behave across devices with mobile responsiveness and tab layout.
## Brand styling (Themes & Custom CSS)
Use [Themes and Custom CSS](/docs/admin/dashboard-themes) to align your dashboards with your application’s design system. You can adjust key visual elements such as:
- **Typography**: Adjust font families, import brand fonts, and customize data label sizes.
- **Colors**: Modify backgrounds, borders, and visualization color palettes.
- **Layout**: Add custom images, shadows, and padding to blocks.
- **Tables**: Fine-tune font sizes and borders for table visualizations.
**How to use:**
1. [Create a Local Theme](/docs/admin/dashboard-themes#create-a-new-local-theme) to customize your dashboard's look and feel to match your brand.
2. (Optional) Add [Custom CSS](/docs/admin/dashboard-themes/custom-css) on top of any theme to fine-tune specific elements.
3. Embed your dashboard. Themes are automatically applied.
:::tip
Want consistent branding across multiple dashboards? [Create a Custom Theme](/docs/admin/dashboard-themes#create-new-custom-themes-or-reusable-themes) and reuse it everywhere.
:::
## Visualization styling
Beyond dashboard-level theming, you can style individual charts and tables to highlight what matters most to your users. Holistics provides [visualization styling options](/docs/visualizations) for:
- **Charts:** Customize [colors](/docs/admin/dashboard-themes/color-palettes), axes, legends, data labels, and series grouping.

- **Tables:** Apply [color themes](/docs/charts/table#table-styles), adjust text spacing, configure borders, and add [conditional formatting](/docs/charts/table#conditional-formatting).

- **KPI metrics:** Control [label sizes, colors, and comparison display modes](/docs/charts/metric-kpi).

All visualization styling you configure is automatically reflected in your embedded dashboards.
View full customization and formatting features in our [Visualizations documentation](/docs/visualizations).
## Build custom visualizations
When standard charts don't fit your specific industry needs, you can create specialized visuals that feel native to your product.
### Custom charts
Standard charts don't always fit your product's needs. [Custom Charts](/docs/charts/custom-charts) let you build domain-specific visualizations using the Vega-Lite framework, such as Gantt charts for project management, Sankey diagrams for marketing, or Sunbursts for file storage.

**How to use:**
1. [Create a Custom Chart](/docs/charts/custom-charts#how-to-add-a-custom-chart) using the Vega-Lite editor.
2. [Add it to your dashboard](/docs/dashboards/visualization-blocks#creating-visualization-blocks) as a visualization block.
3. Embed your dashboard, and your custom charts will render automatically.
Check out more examples in the [Chart Library](/docs/charts/custom-charts/library/).
### Dynamic content blocks
[Dynamic Content Blocks](/docs/charts/dynamic-content-block) let you build product-native UI elements inside your embedded dashboards, such as branded KPI cards, custom headers, or interactive controls styled to match your app. Since they support HTML, CSS, and Markdown with live data from your models, you can replicate your host application's component patterns directly within the dashboard.

*Example: A responsive product card grid, ideal for e-commerce dashboards, product catalogs, and inventory displays.*
**How to use:**
1. [Create a Dynamic Content Block](/docs/charts/dynamic-content-blocks/getting-started) with HTML, CSS, and Markdown.
2. Add it to your dashboard and embed. Your custom UI elements will appear seamlessly.
Check out more examples in the [Gallery](/docs/charts/dynamic-content-blocks/gallery).
## Add-on customizations
Beyond the core styling tools, you can fine-tune how your embedded dashboards behave and present data to your users.
### Mobile responsiveness
If your users access your app on mobile, your embedded dashboards adapt automatically with [Mobile Responsiveness](/docs/canvas-dashboard/mobile-responsive). Choose between an intelligent Auto-stack or a precision Manual layout to control how content reflows on mobile devices.

**How to use:**
1. [Configure mobile layout](/docs/canvas-dashboard/mobile-responsive#how-to-configure-the-mobile-view) for your dashboard (Auto-stack or Manual).
2. Embed your dashboard. The responsive layout applies automatically on mobile devices.
### Tab layout
Organize complex embedded views into multiple [Tabs](/docs/dashboards/tabs), so your users can navigate between different data perspectives within a single dashboard.
**How to use:**
1. [Add tabs](/docs/dashboards/tabs#create-tabs) to your dashboard and organize blocks across them.
2. Embed your dashboard. Tabs are automatically applied in the embedded dashboard.
---
## Customize client dashboards from a shared template
## Introduction
Embedding already lets you serve **one dashboard to many clients**: permissions filter each viewer to their own data, and [Dynamic Data Sources](/embedded/dynamic-data-sources) can route each client to their own database. That covers most multi-client setups without any extra work.
But sometimes clients need dashboards that **look different from each other**, not just show different data. One client wants an extra chart, another wants a different layout. Building each of these from scratch means maintaining several near-duplicate dashboards, where every shared update has to be repeated by hand.
This is where a **master dashboard template** helps: define the shared structure once, then use [AML Extend](/reference/aml/extend) to build each client's dashboard from it, overriding only what's different for that client.
- **Reusability**: update the template once, and the change propagates to every client dashboard that extends it.
- **Client-specific customization**: add or override charts in one client's dashboard without touching the template or any other client.
You can still combine this with per-client data sources when needed. See [Point each client dashboard to its own data source](#point-each-client-dashboard-to-its-own-data-source) below.

## Step-by-step solution
1. **Define a master dashboard template** with reusable chart components.
2. **Build client dashboards from the master dashboard template**. Updating the template should propagate changes to all client dashboards.
3. **Customize specific client dashboards**. Add or override charts in a specific client dashboard without affecting the template or other clients.
4. **(Optional) Point each client dashboard to its own data source**. Combine with a dynamic dataset if clients also need their own data, not just their own charts.
### Define a master dashboard template
Create a master dashboard template as follows:
```tsx
// In master_template.page.aml
// Define master dashboard template
// highlight-next-line
Dashboard master_dashboard_template {
title: 'eCommerce Business Metrics'
// highlight-next-line
block v1: VizBlock {
viz: PieChart {
// A default dataset. Can refer to client's dataset later
// highlight-next-line
dataset: default_dataset
//...
settings {
// This draws the chart as a donut
display_as_donut: false
// ...
}
}
}
// ...
}
```

### Build client dashboards from the master dashboard template
Once you define a dashboard template, use [**AML Extend**](/reference/aml/extend) to build client dashboards. Extending a dashboard lets you inherit all of its properties, including titles, charts, filters, and settings.
```tsx
// In client_dashboard.page.aml
// Extend the master dashboard template into a client dashboard
// highlight-next-line
Dashboard client_dashboard = master_dashboard_template.extend({})
```
The client dashboard will look the same as the master dashboard template.
Let's edit the pie chart in the master dashboard template so that it shows a donut instead of a pie.
```tsx
// In master_template.page.aml
Dashboard master_dashboard_template {
title: 'eCommerce Business Metrics'
block v1: VizBlock {
viz: PieChart {
// A default dataset. Can refer to the client's dataset later
dataset: default_dataset
//...
settings {
// Changed this to `true` to display a donut pie
// highlight-next-line
display_as_donut: true
// ...
}
}
}
// ...
}
```
You should see changes being reflected in the client dashboard.

:::warning Important Note
Please note that, **once you use [Extend](/reference/aml/extend), [Const](/reference/aml/constant), or [Function](/reference/aml/func), you need to define charts, layouts, and positions as code instead of UI**. Editing in UI mode will not work if your code contains AML Extend. This is a known limitation that we're working on removing in the near future.
:::
By extending the master dashboard template, you can add/edit the template and have changes propagated to all client dashboards automatically.
### Customize specific client dashboards
You can also use `AML Extend` to override a chart in the dashboard template. Define a new bar chart and use this instead and of the pie chart.
```tsx
// In client_dashboard.page.aml
// highlight-next-line
Dashboard client_dashboard = master_dashboard_template.extend({
// Define a bar chart for revenue share instead of a pie chart
// highlight-next-line
block v1: BarChart {
dataset: default_dataset
// ...
}
}
})
```
Now the client dashboard will use the bar chart instead of the pie chart. This doesn't affect the dashboard template or any other client dashboards you may have.

### Point each client dashboard to its own data source
So far, every client dashboard has used the same `default_dataset`. If your clients also need to see their own data, not just their own charts, you can pass a dataset into a chart via an [AML Function](/reference/aml/func):
```tsx
// In client_dashboard.page.aml
// Refactor this function to accept a dataset param
// highlight-next-line
Func getRevenueShareBarChart(dataset_param) {
VizBlock {
viz: BarChart {
// Use the dataset param instead of hard-coded value
// highlight-next-line
dataset: dataset_param
// ...
}
}
}
Dashboard client_dashboard = master_dashboard_template.extend({
// Call the function with the client's actual data source
// highlight-next-line
block v1: getRevenueShareBarChart(client_dataset);
})
```
For most cases, prefer [Dynamic Data Sources](/embedded/dynamic-data-sources): it routes each client to their own database automatically based on the embed payload, so you don't have to pass a dataset into every chart manually.
## See also
- [Dynamic Data Sources](/embedded/dynamic-data-sources): route each client's dashboard to their own database automatically based on the embed payload.
- [AML Extend](/reference/aml/extend): full reference for extending dashboards, datasets, and other AML objects.
---
## Dynamic data sources for embedded analytics
:::info
This feature is available on select plans. If your team does not have access to this feature, please contact [support@holistics.io](mailto:support@holistics.io).
:::
## Introduction
When implementing embedded analytics, you often need to **serve customers who each have their own separate databases**. Rather than creating duplicate dashboards for every customer, you can configure a single embedded dashboard that **dynamically connects to the appropriate database** based on which customer is viewing it.
This guide walks you through setting up [Dynamic Data Sources](/docs/development/dynamic-data-source.md) for embedded analytics. You'll learn how to pass [user attributes](/docs/admin/user-attributes) in your embed payload and have Holistics automatically route queries to the correct database.
## How it works
Before diving into implementation, here's how the dynamic data source mechanism works at a high level:

The flow works like this:
1. **Your backend identifies the customer** - When a user accesses your embedded dashboard, your application determines which database they should connect to
2. **You pass user attributes in the embed payload** - Your backend includes user attributes (e.g., `data_source`, `schema`, or any custom user attribute) in the signed embed token
3. **Holistics routes the query** - When the dashboard loads, Holistics reads the user attributes and uses them to evaluate dynamic expressions in your AML code (e.g., routing queries to the correct database)
This approach lets you maintain a single dashboard definition while serving data from multiple customer databases.
:::tip
For a deeper understanding of the underlying mechanism and all available variables (user attributes, system attributes, built-in variables), see [Dynamic Data Source](/docs/development/dynamic-data-source.md).
:::
## Dynamic data source implementation
Follow these steps to configure dynamic database connections for your embedded dashboards.
### Step 1: Connect your customers' databases
First, [connect](/docs/connect/) all of your customers' databases to Holistics.
When naming each data source, use a unique and meaningful identifier (e.g., `customer_acme`, `customer_globex`). You'll reference these names in your embed payload to specify which database to use.
### Step 2: Create user attributes
Before you can pass user attributes in your embed payload, an admin must manually create them in Holistics first. User attributes are not auto-created. **If you pass a user attribute in the embed payload that doesn't exist in Holistics, it will be ignored**.
For dynamic data sources, you'll typically create a user attribute named `data_source` (or any name you prefer, as long as it matches what you reference in your AML code).
Navigate to **Settings > User Attributes** and create the user attributes you need. See [User Attributes Documentation](/docs/admin/user-attributes#add-new-user-attributes) for detailed instructions.

You can use `H.current_user.` in your AML code to access any user attribute passed through the embed payload. This works with any custom user attribute, not just `data_source`.
### Step 3: Create a dynamic variable in AML
Next, create an AML variable that reads the user attribute and provides a fallback value.
Go to the **Development** tab, create a new file (e.g., `embedded_variables.aml`), and add:
```aml
const ds_name = if (H.current_user.data_source) {
H.current_user.data_source
} else {
'default_data_source'
}
```
Setting a fallback value (like `'default_data_source'`) is recommended. If you forget to pass the user attribute in your embed payload, the fallback ensures your embedded dashboard still loads instead of showing an error.
:::tip
You can name the file and variable anything you like, as long as the file ends with `.aml` and the variable name is valid AML syntax.
:::
### Step 4: Update your dataset configuration
Modify the dataset that powers your embedded dashboard to use the dynamic variable. Since AML constants are globally accessible, you can reference `ds_name` from any file in your project.
```aml
Dataset dynamic_client_dataset {
label: 'Dynamic Client Dataset'
// highlight-next-line
data_source_name: ds_name
models: [orders]
relationships: []
}
```
Now the dataset will connect to whichever data source is specified in the embed payload.
### Step 5 (Optional): Test in embed sandbox
Before wiring everything together, you can use the sandbox to verify your configuration so far. Test with different user attribute values (e.g., `data_source`, `schema`, or any custom user attribute) and confirm that queries route to the expected databases.
Depending on which embedding method you're using, follow the appropriate section below:
#### Single Dashboard Embedding
Use the [Embedded Analytics Sandbox](/embedded/single-dashboard/basic-settings) to test your dynamic data source configuration.

#### Embed Portal
You can test your embed portal's dynamic data source configuration in two ways:
- **Preview in Development**: test directly from the Development tab
- **Embed Portal Sandbox**: available in **Tools > Embedded Analytics** after you published your changes

### Step 6: Pass user attributes in your embed payload
Finally, when generating the embed token on your backend, include the user attributes that your AML code references:
```tsx
// Embed payload
embed_payload = {
permissions: {},
user_attributes: {
// highlight-start
data_source: ['customer_acme'], // Routes queries to the right database
// highlight-end
region: ['us-west'], // Any custom user attribute your AML code references
}
}
```
Each user attribute value is an **array of strings** (even when you're passing a single value). These values become accessible in your AML code via `H.current_user.` (e.g., `H.current_user.data_source`, `H.current_user.region`).
Your backend logic should determine the correct user attribute values based on your customer identification system.
:::warning Remember to publish
Your AML changes (dynamic variables, dataset configuration) only take effect in production after you [publish](/docs/quickstart#publish) them. Make sure to publish before testing your embed integration.
:::
## Alternative: Dynamic schema
If your customers share the same database but use different schemas (common in multi-tenant architectures), you can use `schema` instead of `data_source`. The setup follows the same pattern. The key difference is where you apply the variable.
Instead of setting `data_source_name` at the **dataset** level, you interpolate the schema into `table_name` or `query` at the **model** level:
```aml
// Define the variable (same pattern as data_source)
const schema_name = if (H.current_user.schema) {
H.current_user.schema
} else {
'default_schema'
}
// Use in TableModel
Model dynamic_model {
type: 'table'
data_source_name: 'my_wh'
// highlight-next-line
table_name: '${schema_name}.cities'
}
// Or in QueryModel
Model dynamic_query_model {
type: 'query'
data_source_name: 'my_wh'
query: @sql
// highlight-next-line
select * from ${schema_name}.cities
;;
}
```
Then pass the schema in your embed payload:
```tsx
embedded_payload = {
permissions: {},
user_attributes: {
schema: ['tenant_acme']
}
}
```
## See also
- [Dynamic Data Sources](/docs/development/dynamic-data-source.md): full reference including available variables, system user attributes, and built-in variables like `H.git.is_production`
- [Dynamic Schemas](/docs/development/dynamic-schema.md): switch schemas within the same database
- [User Attributes](/docs/admin/user-attributes): managing user attributes in Holistics
---
## Embedded Analytics - Authentication
We use JWT (JSON Web Token) as a mechanism of user authentication. This is how it works:
- When a customer visits your app that needs embedding Holistics, your backend will take the customer ID and generate a token based on the secret key above.
- You then render an iframe pointing to the embed link, with the token baked into it.
- Holistics then use this token to authenticate and figure out which Customer is logging in, and display your dashboard with only that customer's data.
You are required to issue an encrypted token for your customer. The token is for us to:
- Correctly identify which of your customers is viewing the dashboard.
- Prevent your customers from faking their identity by simply changing the parameters inside the URL.
- Expire the token after a specified period of time.
---
## Email subscriptions for embed users
## Introduction
Email subscriptions **let embed users schedule dashboards to be emailed to themselves regularly**, so they can stay up to date without logging into your app.
## Functionality
Once you enable Email Subscriptions, your embed users can create and manage their own email schedules directly.
### Creating an email subscription
Users can subscribe to dashboards and receive reports on a schedule that works for them. They can customize what they receive by choosing the frequency, applying filters, and selecting a format like PNG, PDF, CSV, or Excel.
### Managing email subscriptions
Users can view, edit, pause, or delete their subscriptions from the subscription management panel.
## Setup instructions
Email subscriptions are only available when you embed using [Embed Portal](/embedded/embed-portal/).
To enable Email Subscriptions, include [user identity](/embedded/identity-workspace) and email address in your embed payload, then enable the dashboard export and data subscription permissions. See the sample code below for implementation details.
### Full setup (recommended)
```javascript
const embed_payload = {
// your embed portal information
object_type: 'EmbedPortal',
object_name: 'your_portal_name',
//highlight-start
// 1) Required: embed user identity (used to own/manage subscriptions)
embed_user_id: 'user_123',
// Optional: organization identifier (recommended for multi-tenant; user identified by embed_org_id + embed_user_id)
embed_org_id: 'org_123',
// 2) Required: Email address where subscription emails will be delivered
embed_user_email: 'user@example.com',
// 3) Recommended: User attributes used for row-level permissions
user_attributes: {
region: 'APAC',
company_id: 123,
},
settings: {
// 4) Required: embed users need dashboard export permission to set up email subscriptions
allow_dashboard_export: true,
// 5) Required: enable the Email Subscriptions feature
allow_data_subscribe: true,
// 6) Optional: allow raw data formats (CSV/Excel) in email subscriptions
allow_raw_data_export: true,
}
//highlight-end
}
```
### Minimal setup (required only)
```javascript
const embed_payload = {
object_type: 'EmbedPortal',
object_name: 'your_portal_name',
//highlight-start
embed_user_id: 'user_123',
embed_user_email: 'user@example.com',
settings: {
allow_dashboard_export: true,
allow_data_subscribe: true,
},
//highlight-end
}
```
## How Holistics stores embed user context
Email Subscriptions run on a schedule and may execute when an embed user is not actively using your application. To support this, Holistics stores a minimal set of embed user context from your embed payload so subscriptions can execute reliably and with the correct access controls.
### What's stored and how it's used
When an embed user accesses the embedded iframe, Holistics stores:
- Identity: `embed_user_id` and `embed_org_id` (if provided). Used to identify the embed user and retrieve the correct stored context.
- Delivery email: `embed_user_email`. Where subscription emails are sent
- Row-level permission attributes: `user_attributes`. Used to apply row-level permissions during subscription execution
- `permissions` and `settings`: Used to validate what the embed user can export when generating the subscription output (dashboard access and available formats).
When a subscription runs, Holistics uses the latest stored embed user context to generate and deliver the email. Holistics only stores what’s needed to execute embedded queries and subscriptions (no additional user profile data).
### How users are identified
Holistics identifies an embed user using `embed_org_id` + `embed_user_id` (see [Identity and Workspace Settings](/embedded/identity-workspace)).
### How updates are applied
When an embed user accesses the embedded iframe, Holistics updates the stored embed user context from the latest payload (including embed_user_email, user_attributes, permissions, and settings).
As a result, updates in the embed payload take effect starting with **the next scheduled subscription run after that iframe access**.
## Admin: Managing email subscriptions
As an admin, you can view and manage all subscriptions created by your embed users.
### Viewing all email subscriptions
1. Navigate to **Tools → Embedded Analytics**
2. Select your embed portal
3. Click **Manage subscriptions**
From here, admins can see all email subscriptions across your embed users, including their schedules, formats, and delivery status.
### Managing user subscriptions
Admins have full control over user subscriptions:
- **Pause**: Temporarily stop email delivery without deleting the subscription
- **Delete**: Permanently remove a subscription
- **View details**: See subscription configuration and delivery history
## FAQs
### What happens if `embed_user_email` changes?
Emails will be delivered to the **updated address** starting with the **next scheduled execution** after the embed user accesses the embedded iframe.
### Can embed users change the delivery email themselves?
**No.** The delivery email is controlled by **your application** via `embed_user_email` in the embed payload. Embed users **cannot edit it** in the subscription UI. This is intentional to keep email delivery under your control and reduce the risk of data being sent to an unintended address.
---
## Embed events: Control embedded dashboards with JavaScript
## Introduction
**Embed events** let you control embedded Holistics dashboards from your application using JavaScript. Send commands, listen for user interactions, and keep your app and dashboard in sync, all through the browser's standard [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) API.
Use cases include:
- **Filter sync:** Push filter values from your app into the dashboard, or listen when users change filters inside the iframe and apply those values elsewhere in your app.
- **Dashboard navigation:** Build a custom menu (horizontal nav, tab-style switcher) in your app that triggers dashboard changes inside the iframe with a smooth, no-reload experience.
- **Error handling & token refresh:** React to errors from the iframe by showing contextual messages to users, notifying admins, or automatically refreshing an expired JWT token to keep the session alive.
## How it works
Communication flows in two directions between your application (the host) and the Holistics iframe (embedded analytics):

**Host to iframe:** Your app sends commands (e.g., update filters, navigate to a different dashboard) by calling `iframe.contentWindow.postMessage()`.
**Iframe to host:** The embedded dashboard emits events (e.g., filter changed, navigation completed) via `parent.postMessage()`, and your app listens with `window.addEventListener("message", ...)`.
## Supported events
| Event | Direction | Description |
|-------|-----------|-------------|
| [`embed:loaded`](#embedloaded) | **iframe** to **host** | Emits when the embed is ready. Includes a list of all available dashboards and datasets. |
| [`page:change`](#pagechange) | **host** to **iframe** | Switch the iframe to a different dashboard or object without reloading. |
| [`page:changed`](#pagechanged) | **iframe** to **host** | Emits after a navigation action completes. Echoes back the current page path. |
| [`dashboard:run`](#dashboardrun) | **host** to **iframe** | Refresh the current dashboard. |
| [`dashboard:loaded`](#dashboardloaded) | **iframe** to **host** | Emits once when the dashboard finishes its initial load. Includes metadata and default filter values. `dashboard:filters:applied` does not emit during this load. |
| [`dashboard:filters:apply`](#dashboardfiltersapply) | **host** to **iframe** | Apply filter values to the current dashboard. Only takes effect after `dashboard:loaded` has emitted. |
| [`dashboard:filters:applied`](#dashboardfiltersapplied) | **iframe** to **host** | Emits when the user or `dashboard:filters:apply` changes filter values, after `dashboard:loaded`. |
| [`embed:token:refresh`](#embedtokenrefresh) | **host** to **iframe** | Pass a new JWT token to extend the embed session. |
| [`embed:error`](#embederror) | **iframe** to **host** | Emits when an error occurs inside the embed. |
### Host to iframe
Your app sends these commands to control the embedded dashboard:
#### `page:change`
Switch the iframe to a different dashboard or object without reloading.
Event structure:
```javascript
{
eventName: "page:change",
payload: {
path: string,
}
}
```
Event properties:
Path to navigate to. Use /objects/{'{'}object_name{'}'} for a dashboard or dataset, or /ai for the Ask AI page.
---
#### `dashboard:filters:apply`
Apply filter values to the current dashboard and reload its data without reloading the iframe. This command only takes effect after `dashboard:loaded` has emitted.
Event structure:
```javascript
{
eventName: "dashboard:filters:apply",
payload: {
filters: {
[block_name]: {
operator: string,
values: string[],
modifier?: string,
}
}
}
}
```
Event properties:
Map of filter block names to filter values. Each entry includes the following properties:operator: Filter operator (e.g., "is", "is_in_the_range").values: Array of filter values to apply.modifier: Optional. Filter modifier (e.g., for relative date ranges).
---
#### `dashboard:run`
Refresh the current dashboard.
Event structure:
```javascript
{
eventName: "dashboard:run",
}
```
No payload required.
---
#### `embed:token:refresh`
Pass a new JWT token to extend the embed session without reloading the iframe.
Event structure:
```javascript
{
eventName: "embed:token:refresh",
payload: {
token: string,
}
}
```
Event properties:
The new JWT token to replace the current session token.
---
### Iframe to host
The embedded dashboard emits these events to notify your app:
#### `embed:loaded`
Emits when the embed is ready. Provides a list of all dashboards and datasets available in the embed portal, useful for building custom navigation.
Event structure:
```javascript
{
eventName: "embed:loaded",
payload: {
objects: {
dashboards: [
{
uname: string,
id: string,
title: string,
path: string,
workspace?: 'org' | 'personal',
}
],
datasets: [
{
uname: string,
id: string,
title: string,
}
],
}
}
}
```
Event properties:
List of dashboards available in the embed. Each item includes the following properties:uname: Unique identifier for the dashboard.id: Dashboard ID.title: Display title.path: URL path to the dashboard.workspace: Optional. 'org' if the dashboard belongs to the organization workspace, 'personal' if it belongs to a personal workspace.
List of datasets available in the embed. Each item includes the following properties:uname: Unique identifier for the dataset.id: Dataset ID.title: Display title.
---
#### `dashboard:loaded`
Emits once when the dashboard finishes its initial load. At this point, dashboard metadata and default filter values are available to the host app. Note: `dashboard:filters:applied` only emits for filter changes made after this event. The initial filter values captured here do not trigger it.
Event structure:
```javascript
{
eventName: "dashboard:loaded",
payload: {
dashboard: {
uname: string,
id: string,
title: string,
path: string,
workspace?: 'org' | 'personal',
},
filters: {
[block_name]: {
operator: string,
values: unknown[],
modifier?: string,
}
}
}
}
```
Event properties:
Metadata about the loaded dashboard:uname: Unique identifier for the dashboard.id: Dashboard ID.title: Display title.path: URL path to the dashboard.workspace: Optional. 'org' or 'personal'.
The dashboard's current filter values at load time. Each entry includes the following properties:operator: Filter operator.values: Array of current filter values.modifier: Optional. Filter modifier.
---
#### `page:changed`
Emits after a navigation action completes. Echoes back the current page path so your app can stay in sync.
Event structure:
```javascript
{
eventName: "page:changed",
payload: {
path: string,
}
}
```
Event properties:
The current page path after navigation (e.g., /objects/{'{'}object_uname{'}'} or /ai).
---
#### `dashboard:filters:applied`
Emits whenever filter values change on the dashboard, either by the user or programmatically via `dashboard:filters:apply`. This event is only active after `dashboard:loaded` has emitted; filter changes during the initial load (including default filter values) do not trigger it. Only the changed filters are included in the payload.
Event structure:
```javascript
{
eventName: "dashboard:filters:applied",
payload: {
dashboard: {
uname: string,
id: string,
title: string,
type: string,
},
filters: {
[block_name]: {
operator: string,
values: unknown[],
modifier?: string,
}
}
}
}
```
Event properties:
Metadata about the dashboard where filters were applied:uname: Unique identifier for the dashboard.id: Dashboard ID.title: Display title.type: Dashboard type.
Map of filter block names to their updated values. Only changed filters are included. Each entry includes the following properties:operator: Filter operator.values: Array of current filter values.modifier: Optional. Filter modifier.
---
#### `embed:error`
Emits when an error occurs inside the embed (e.g., invalid object name, permission denied, or token expired).
Event structure:
```javascript
{
eventName: "embed:error",
payload: {
error: {
message: string,
}
}
}
```
Event properties:
Human-readable description of the error.
## Use cases
The examples below assume you have a Holistics dashboard embedded in an iframe like this:
```html
```
All code samples reference this iframe by its `id="holistics-embed"` and use `https://your-holistics-domain.com` as the origin for `postMessage` calls.
### Sync filters between your app and the dashboard
Your app has its own UI controls (dropdowns, date pickers, search bars) alongside an embedded dashboard. When a user interacts with filters on either side, you want both to stay in sync.
**Example:** An e-commerce platform has a region dropdown in the app's header bar. Below it, a sales dashboard is embedded. When a user picks "Europe" from the dropdown, the dashboard should filter to European data. Conversely, if the user changes the region filter inside the dashboard, the app's dropdown should update to match.
This works in two directions:
**App to dashboard:** Your app pushes filter values into the iframe when the user interacts with your UI.
```js
// Push filters back into the iframe
const iframe = document.getElementById("holistics-embed");
iframe.contentWindow.postMessage(
{
eventName: "dashboard:filters:apply",
payload: {
filters: {
building_id: {
operator: "is",
values: ["123"],
},
date_range: {
operator: "is_in_the_range",
values: ["last_30_days"],
},
},
},
},
"https://your-holistics-domain.com"
);
```
**Dashboard to app:** The iframe emits a `dashboard:filters:applied` event when the user changes a filter inside the dashboard, and your app listens and updates its own state.
```js
// Listen for filter changes from the iframe
window.addEventListener("message", (event) => {
if (event.origin !== "https://your-holistics-domain.com") return;
if (event.data?.eventName === "dashboard:filters:applied") {
const { filters } = event.data.payload;
// Store filter state in your app
myApp.filters.set(filters);
}
});
```
### Persist filter values across sessions
Users often expect their filter selections to carry over the next time they open a dashboard. If someone filters a sales dashboard down to their own team today, they shouldn't have to redo that filtering tomorrow.
The iframe itself doesn't persist state between page loads, so you handle this in your app: capture filter values as the user sets them, store them wherever makes sense (browser `localStorage` for a single device, or your own backend if you want the values to follow the user across devices), and reapply them the next time that user opens the embed.
**1. Capture and store filter values**
Listen for `dashboard:filters:applied` and save the values:
```js
window.addEventListener("message", (event) => {
if (event.origin !== "https://your-holistics-domain.com") return;
if (event.data?.eventName === "dashboard:filters:applied") {
const { filters } = event.data.payload;
// Store all filter values to localStorage
const stored = JSON.parse(localStorage.getItem("dashboard_filters") || "{}");
localStorage.setItem("dashboard_filters", JSON.stringify({ ...stored, ...filters }));
}
});
```
**2. Reapply stored values on the next visit**
How you reapply the values depends on what you're embedding:
- **Single-dashboard embedding — apply filter values via `default_condition`:** On your backend, read the stored values and set them as `default_condition` in your JWT payload's [control settings](/embedded/single-dashboard/basic-settings#control-settings) before signing the token. Since this happens before the iframe loads, it's the most reliable option for this embed type:
```js
// last_used_filters:
// - front-end stores this in localStorage and sends it with the generate_embed_token API call
// - or get it from your backend if you store it in your database instead.
function generateEmbedToken(userId, last_used_filters) {
// Each stored filter already has the { operator, values, modifier } shape
// that default_condition expects, so just wrap it per filter_uname
const filters = Object.fromEntries(
Object.entries(last_used_filters).map(([filterUname, condition]) => [
filterUname,
{ default_condition: condition },
])
);
const payload = {
// ...rest of your JWT payload (settings, permissions, exp, etc.)
filters,
};
const token = jwt.sign(payload, embed_secret, { algorithm: "HS256" });
return token;
}
```
- **Embed Portal — apply filter values when dashboard is loaded:** Reapply the stored values with `dashboard:filters:apply`, sending this event once `dashboard:loaded` emits for the current dashboard:
```js
window.addEventListener("message", (event) => {
if (event.origin !== "https://your-holistics-domain.com") return;
if (event.data?.eventName === "dashboard:loaded") {
const filters = JSON.parse(localStorage.getItem("dashboard_filters") || "{}");
// Apply filter values to the current dashboard and reload its data without
// reloading the iframe. Only takes effect after dashboard:loaded has emitted.
iframe.contentWindow.postMessage(
{ eventName: "dashboard:filters:apply", payload: { filters } },
"https://your-holistics-domain.com"
);
}
});
```
### Navigate between dashboards without reloading the iframe
You may want to build your own navigation for embedded dashboards: a tab-style switcher, a sidebar menu, or dashboards nested inside your app's existing navigation hierarchy. Instead of reloading the iframe each time, use `page:change` to switch dashboards seamlessly.
**Example:** A SaaS platform has a horizontal tab bar with "Overview", "Sales", and "Support" tabs. Each tab maps to a different embedded dashboard. Clicking a tab sends a `page:change` command, and the iframe swaps the dashboard instantly without a full reload.
```js
const iframe = document.getElementById("holistics-embed");
function switchDashboard(dashboardName) {
iframe.contentWindow.postMessage(
{
eventName: "page:change",
payload: { path: `/objects/${dashboardName}` },
},
"https://your-holistics-domain.com"
);
}
// Wire up your custom tab navigation
document.querySelector('[data-tab="overview"]').onclick = () => switchDashboard("overview");
document.querySelector('[data-tab="sales"]').onclick = () => switchDashboard("sales_overview");
document.querySelector('[data-tab="support"]').onclick = () => switchDashboard("support_metrics");
```
The iframe confirms the navigation with a `page:changed` event, echoing back the new path so your app can track which action completed and update the active tab state.
### Trigger a dashboard refresh after external changes
When your app modifies data that the dashboard depends on (e.g., user submits a form, imports a file, or completes an action), you can tell the embedded dashboard to refresh so it reflects the latest data.
```js
async function handleFormSubmit(data) {
await saveToDatabase(data);
const iframe = document.getElementById("holistics-embed");
iframe.contentWindow.postMessage(
{ eventName: "dashboard:run", payload: {} },
"https://your-holistics-domain.com"
);
}
```
### Track user interactions for analytics or logging
Listen for events from the embedded dashboard to understand how your users interact with analytics. You can log filter changes, navigation, and errors to your own analytics or monitoring system.
```js
window.addEventListener("message", (event) => {
if (event.origin !== "https://your-holistics-domain.com") return;
const { eventName, payload } = event.data;
switch (eventName) {
case "dashboard:filters:applied":
analytics.track("embed_filter_changed", payload);
break;
case "page:changed":
analytics.track("embed_navigation", payload);
break;
case "embed:error":
errorTracker.capture("embed_error", payload);
break;
}
});
```
### Handle errors and refresh expired tokens
The iframe emits `embed:error` events when something goes wrong (invalid object name, permission denied, or an expired token). Your app can listen for these and respond accordingly: show a contextual error message, notify an admin, or automatically refresh the token to keep the session alive.
```js
const iframe = document.getElementById("holistics-embed");
window.addEventListener("message", async (event) => {
if (event.origin !== "https://your-holistics-domain.com") return;
if (event.data?.eventName === "embed:error") {
const { error } = event.data.payload;
if (error.code === "token_expired") {
// Generate a new token and push it into the iframe without reloading
const newToken = await generateEmbedToken();
iframe.contentWindow.postMessage(
{
eventName: "embed:token:refresh",
payload: { token: newToken },
},
"https://your-holistics-domain.com"
);
} else {
// Show error to the user or notify your team
showErrorToast(`Embed error: ${error.message}`);
notifyAdmin(error);
}
}
});
```
## Security best practices
Always validate the `event.origin` before processing any message. This prevents other iframes or windows from spoofing events:
```js
window.addEventListener("message", (event) => {
// Only accept messages from your Holistics domain
if (event.origin !== "https://your-holistics-domain.com") return;
// Process event...
});
```
---
## Embed Portal
:::info
This feature is available on select plans. If your team does not have access to this feature, please [contact us](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54).
:::
## Introduction
Embed Portal allows developers to **embed a mini BI application within their application**. Developers can distribute **multiple dashboards** to embed users, as well as allow **embed users to self-serve and customize their own dashboards**.

Benefits:
- **Easy Content Management**: Add or remove embedded dashboards and datasets through a simple interface. Your analysts can manage embedded content directly without involving your engineering team.
- **Self-Service Analytics**: Let your users [explore data, interact with charts](/embedded/self-serve-exploration#data-exploration), and [build their own custom dashboards](/embedded/identity-workspace) right within your application.
- **Multi-Tenant Collaboration**: Set up separate organizations with secure data boundaries, where each organization only sees their own data. Within each organization, users can share dashboards and collaborate with customizable permissions for different user roles.
- **Subscription-Based Access**: Control what analytics features each user sees based on their subscription level or user tier. Give premium users access to advanced features while keeping basic features for standard users.
- **Developer-Friendly Setup**: Configure everything through code, test integrations in a sandbox environment, and use preview environments to streamline your development process.
## How it works
1. **Define:** Developers define an *embed portal* that contains multiple data assets (dashboards and datasets) along with settings and permission rules in code.
2. **Publish & Integrate:** After publishing, integrate the portal into your web application using a single embed secret key and iframe.
3. **Access Control:** Row-level permissions defined in datasets apply automatically to all embedded content, with user attributes in the payload determining what data is visible.
4. **User Organization:** Embedded users and organizations are identified via `embed_user_id` and `embed_org_id` parameters, enabling personal workspaces and shared organizational environments.
5. **Self-service Analytics:** Embedded users can explore data, create dashboards, and collaborate based on their permissions (all within your application).

## Prerequisites
Before using Embed Portal, make sure you have:
- **Enabled Advanced features** in your account. See [Enable Embedded Analytics](/embedded/quickstart#enable-embedded-analytics) for instructions.
- **[Enabled git flow](/docs/git-version-control)** for your workspace.
- If you [connect Holistics to your own repository](/docs/git-version-control/external-git), make sure there are [**no protected branch restrictions**](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule).
## Getting started
Creating an Embed Portal involves four main steps:
**Setup in Holistics**
**1. Define** your portal in a `.embed.aml` file
**2. Generate** embed credentials
**Integrate into your application**
**3. Backend** Token Generation
**4. Frontend** Iframe Presentation
## Step 1: Define your portal
### Create your portal file
* Create a new file in your Development tab with the naming convention: `*.embed.aml`
* Add dashboards and datasets that you want to embed into the Embed Portal
```tsx title="sales_portal.embed.aml"
EmbedPortal sales_portal { // object with a unique name
objects: [ // array containing the dashboard and dataset names.
sales_overview, // dashboard
revenue_trends, // dashboard
sales_data, // dataset
// Since objects are unique across your Holistics instance, you can simply list the object names
// Holistics automatically detects whether each object is a dashboard or dataset.
],
}
```
### Choosing what to embed
- **Dashboards**: Deliver instant, actionable insights through pre-built reports (no configuration required).
- **Datasets**: Enable powerful data exploration (let users slice and dice data to create custom visualizations on demand).
### Preview in development
You can preview your embed portal before publishing to production by navigating to the **Preview** tab. It helps you validate changes, catch errors, and test configurations in development.
### Publish to production
Once you're satisfied with the preview, publish your portal to make it available for embedding.
For more information, please refer to [**publishing to production**](/docs/development/dev-prod-mode#publishing-to-production-go-live).
## Step 2: Generate embed credentials
1. Navigate to **Tools → Embedded Analytics**
2. Find your published portal in the list
3. Click **Enable** to activate embed credentials
4. Note your **Key ID** and **Secret** - you'll need these for integration
:::note
You only need to generate embed credentials once; the same credentials can be used for all your embedded portals.
:::
## Step 3: Backend token generation
To embed your portal into your application, you need to generate secure tokens on your backend.
In your application backend, generate embed tokens using this structure:
```javascript
// Basic embed payload
const embed_payload = {
object_name: 'customer_analytics',
object_type: 'EmbedPortal',
settings: {
default_timezone: "UTC",
allow_raw_data_export: false,
}
};
// Generate token (implementation depends on your backend)
const token = jwt.sign(embed_payload, embed_secret, { algorithm: 'HS256' });
```
## Step 4: Frontend integration
### Basic iframe implementation
Generate this URL in your backend. Use the app domain for your Holistics data center.
App domain
```html
```
```html
```
```html
```
### Optional: Call API to generate embedded URL (or embedded token)
```js
// Frontend JavaScript example
async function loadPortal(userId, orgId) {
// Call your backend to generate token
const response = await fetch('/api/embed-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
portal: 'customer_analytics',
})
});
const { embedUrl } = await response.json();
// Update iframe source
document.getElementById('portal-iframe').src = embedUrl;
}
```
## Next steps
Once you have your basic embed working, explore these capabilities:
- **[Parameters Reference](/embedded/embed-portal/parameters-reference)** - Complete reference for all payload and URL parameters
- **[Row-level Permission](/embedded/embed-portal/permission-settings)** - Control which data each user can access
- **[Identity & Workspace Settings](/embedded/identity-workspace)** - Set up user identities and workspaces
- **[Self-Serve Exploration](/embedded/self-serve-exploration)** - Enable data exploration and drill-through
---
## Self-serve exploration
:::info
This feature is available on select plans. If your team does not have access to this feature, please [contact us](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54).
:::
## Introduction
Interactive embedding goes beyond displaying static dashboards in your app. It **lets your embed users actively explore data, drill down into details, and discover insights on their own**. This also reduces time-consuming ad-hoc data requests for your team and lets your customers find answers faster.
## Data exploration
A drag-and-drop interface that lets embed users modify charts or build their own visualizations on the fly: change visualization types, add filters, group data differently, and adjust time ranges.
**How to set up:** Include the underlying datasets in your embed portal. Embed users can only explore data from datasets they have access to:
```javascript title="your_embed_portal.embed.aml"
EmbedPortal ecommerce_portal {
objects: [
sales_dashboard,
// highlight-start
// Users can explore data from this dataset
sales_dataset,
// highlight-end
],
}
```
To learn more, see our [data exploration guide](/docs/data-exploration).
## Drill-down & break down
Click any data point to "slice" it by a different dimension and see exactly what is driving the numbers. Split any metric by different dimensions (break down) or click a specific data point to filter and dig deeper (drill down).
**How to set up:** Include the underlying datasets in your embed portal. Embed users can only explore data from datasets they have access to:
```javascript title="your_embed_portal.embed.aml"
EmbedPortal ecommerce_portal {
objects: [
sales_dashboard,
// highlight-start
// Users can drill down and break down behind any chart from this dataset
sales_dataset,
// highlight-end
],
}
```
To learn more, see our [drill down & break down guide](/docs/interactions/drill-down).
## View underlying data
Instantly inspect the raw row-level records behind any chart. Instead of just seeing a number, embed users can explore "what makes up this value" at the most granular level.
**How to set up:** Include the underlying datasets in your embed portal. Embed users can only explore data from datasets they have access to:
```javascript title="your_embed_portal.embed.aml"
EmbedPortal ecommerce_portal {
objects: [
sales_dashboard,
// highlight-start
// Users can view the raw records behind any chart from this dataset
sales_dataset,
// highlight-end
],
}
```
To learn more, see our [view underlying data guide](/docs/interactions/view-underlying-data).
## Drill-through
Navigate between related dashboards while automatically carrying over filters for a continuous data journey.
**How to set up:** First, [configure drill-through paths](/docs/interactions/drill-through#set-up-drill-through-at-the-target-dashboards) between your dashboards in Holistics. Define which dashboard connects to which (e.g., "Sales Overview" drills to "Product Details").
Then include all connected dashboards in your embed portal:
```javascript title="your_embed_portal.embed.aml"
EmbedPortal ecommerce_portal {
objects: [
// highlight-start
// All dashboards in the drill-through chain must be included
sales_overview_dashboard, // Starting point
product_details_dashboard, // Drill target
customer_analysis_dashboard // Another drill target
// highlight-end
],
}
```
To learn more, see our [drill-through guide](/docs/interactions/drill-through).
## Ask AI & analyze
Ask questions in plain English to generate insights instantly or use AI to summarize complex chart trends.
**How to set up:** See our [Ask AI embedding guide](/embedded/ask-ai) for setup instructions.
## Save explorations to dashboards
All interaction results can be saved to a dashboard. Embed users can save to their personal workspace for private use, or to a shared workspace to collaborate with others.
**How to set up:** This requires Git integration and additional configuration. See [user-built dashboards](/embedded/user-built-dashboards) to enable this feature.
---
## Embed portal parameters reference
This reference covers all parameters for your embed portal. Use them to control access, permissions, and UI behavior.
**How parameters work:**

Embed Portal supports two types of parameters:
| | [**Payload parameters**](#payload-parameters-jwt-signed) | [**URL parameters**](#url-parameters-not-signed) |
| ------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Location** | [Signed JWT token](/embedded/security#secret-key) | Appended to embed URL |
| **Purpose** | Security, permissions, data access (e.g., enable/disable raw data export, set row-level data permissions) | UI, navigation, display preferences (e.g., expand/collapse navigation panel, deep-link to specific dashboard) |
| **User modifiable** | No | Yes |
## Payload parameters (JWT-signed)
Payload parameters let you create personalized, secure analytics experiences for each user. These parameters are included in your JWT token and control security-sensitive aspects like identity, permissions, and data access.
Since these are cryptographically signed, users can't modify them (making them perfect for security-critical settings like data access and permissions).
### Basic configuration
Every embed session needs these parameters to identify which portal to load and ensure tokens expire for security.
| Parameter | Type | Description | Required |
| ------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `object_name` | string | Name of your EmbedPortal as defined in `.embed.aml` | Yes |
| `object_type` | string | Must be `'EmbedPortal'` | Yes |
| `exp` | number | [Token expiration](/embedded/security#token-expiration) time as Unix timestamp (e.g., `1769502969`). Token becomes invalid after this time. | Recommended |
**Example:**
```javascript
const payload = {
object_name: "customer_analytics",
object_type: "EmbedPortal",
exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour
};
```
### User identity
These parameters identify who's viewing the portal and enable personalized features like saving dashboards, email subscriptions, and organization workspaces.
| Parameter | Type | Description | Required |
| ------------------ | ------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `embed_user_id` | string | Unique identifier for the user in your system. See [Identity & Workspace Settings](/embedded/identity-workspace). | For SSBI features |
| `embed_org_id` | string | Unique identifier for the user's organization. See [Identity & Workspace Settings](/embedded/identity-workspace). | For org workspaces |
| `embed_user_email` | string | User's email address for [email subscriptions](/embedded/email-subscriptions) | Required for email subscriptions |
**Example:**
```javascript
const payload = {
// ... basic configuration
embed_user_id: "user_123",
embed_org_id: "org_456",
embed_user_email: "user@example.com",
};
```
### Data access
Control which data each user can see by passing attributes that filter queries at the row level. This ensures users only access data they're authorized to view.
| Parameter | Type | Description | Required |
| ----------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `user_attributes` | object | Key-value pairs to control data access via [row-level permissions](/embedded/embed-portal/permission-settings). Values must be arrays (e.g., `region: ['US']`). Also serves as [Ask AI context](/docs/ai/context/custom-context) for [Embeddable Ask AI](/embedded/ask-ai). | If using row-level permissions or Ask AI |
**Example:**
```javascript
const payload = {
// ... basic configuration and user identity
user_attributes: {
region: ["US", "EU"],
company_id: [123],
department: ["Sales", "Marketing"],
},
};
```
### Settings
Configure feature availability and default behaviors for the embedded portal. These settings control what users can do with the data and how it's displayed.
| Parameter | Type | Description | Default |
| ------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| `settings.ai.enabled` | boolean | Enable [Ask AI](/embedded/ask-ai) feature | `false` |
| `settings.allow_dashboard_export` | boolean | Allow users to export dashboards | `false` |
| `settings.allow_raw_data_export` | boolean | Allow raw data export (CSV, Excel) | `false` |
| `settings.allow_data_subscribe` | boolean | Enable [email subscriptions](/embedded/email-subscriptions) | `false` |
| `settings.default_timezone` | string | Timezone for data display (e.g., `"UTC"`, `"America/New_York"`) | Workspace default |
| `settings.allow_dashboard_timezone_change` | boolean | Allow users to change the current dashboard's timezone | `true` |
| `settings.dashboard_autorun_on_changes` | boolean | Re-run the dashboard automatically whenever filters or controls change, without clicking the Apply button. Overrides the dashboard's internal [auto-run on changes](/docs/dashboards/settings#auto-run-on-changes) setting. | `false` |
**Example:**
```javascript
const payload = {
// ... basic configuration and user identity
settings: {
default_timezone: "America/New_York",
ai: { enabled: true },
allow_dashboard_export: true,
allow_raw_data_export: false,
allow_data_subscribe: true,
allow_dashboard_timezone_change: true,
dashboard_autorun_on_changes: true,
},
};
```
### Permissions
Define what users can create and modify within workspaces. These permissions determine whether users can save their own dashboards or collaborate with their team.
| Parameter | Type | Description | Default |
| --------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `permissions.enable_personal_workspace` | boolean | Allow user to save dashboards to [personal workspace](/embedded/identity-workspace#option-1-individual-users-only-b2c) | `false` |
| `permissions.org_workspace_role` | string | User's role in [organization workspace](/embedded/identity-workspace#option-2-business-users-with-organizations-b2b): `'no_access'`, `'viewer'`, `'editor'` | `'no_access'` |
**Example:**
```javascript
const payload = {
// ... basic configuration and user identity
permissions: {
enable_personal_workspace: true,
org_workspace_role: "editor", // Options: 'no_access', 'viewer', 'editor'
},
};
```
### Full payload example
Here's a complete example combining all parameter types together:
```javascript
const embed_payload = {
// Required: Portal identification
object_name: "customer_analytics",
object_type: "EmbedPortal",
// User identity
embed_user_id: "user_123",
embed_org_id: "org_456",
embed_user_email: "user@example.com",
// Row-level permissions
user_attributes: {
region: ["US", "EU"],
company_id: [123],
},
// Settings
settings: {
default_timezone: "America/New_York",
ai: { enabled: true },
allow_dashboard_export: true,
allow_raw_data_export: false,
allow_data_subscribe: true,
dashboard_autorun_on_changes: true,
},
// Workspace permissions
permissions: {
enable_personal_workspace: true,
org_workspace_role: "editor",
},
// Token expiration (Unix timestamp)
exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour
};
// Generate JWT token
const embed_secret = "YOUR_EMBED_SECRET"; // Replace with your actual secret from Holistics
const token = jwt.sign(embed_payload, embed_secret, { algorithm: "HS256" });
```
### Production checklist
The payload structure is standard, but some values must match how your embed portal and customer identity model are set up. Check these before signing the token and constructing the embed URL:
- `object_name` must match the EmbedPortal name in your `.embed.aml` file.
- `user_attributes` keys must match the attributes used by your row-level permission rules. You can manage user attributes programmatically with the [User Attributes API](/api/v2/reference/user-attributes-list) and [User Attribute Entries API](/api/v2/reference/user-attribute-entries-list).
- `embed_user_id` and `embed_org_id` should use stable identifiers from your own application, based on your individual user or organization workspace model.
- The embed URL must use the correct regional or custom domain and the right `embed_key_id` for your Holistics setup.
- Feature flags such as AI, subscriptions, exports, and workspace permissions should match your product and security policy.
## URL parameters (not signed)
URL parameters give you flexibility to customize the user interface without regenerating JWT tokens. Use them to control UI elements like navigation panel state, deep-link to specific dashboards, or adjust display preferences.
Since they don't affect security or data access, users can safely modify these parameters by changing the URL (perfect for features like bookmarking specific views or sharing links to particular dashboards).
### URL structure
Understanding the URL structure helps you control where users land and how the portal behaves when it opens.
Your embed URL must use the same domain as your Holistics data center. Holistics has three regional app domains: APAC uses `secure.holistics.io`, US uses `us.holistics.io`, and EU uses `eu.holistics.io`. If you're not sure which one your account uses, see [Data Centers](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on).
App domain
```
https://secure.holistics.io/embed/{embed_key_id}/{initial_object}?_token={token}&{query_params}
└──────┬─────┘ └──────┬───────┘ └──┬──┘ └─────┬─────┘
Embed Key Path (optional) Token Query params
```
```
https://us.holistics.io/embed/{embed_key_id}/{initial_object}?_token={token}&{query_params}
└──────┬─────┘ └──────┬───────┘ └──┬──┘ └─────┬─────┘
Embed Key Path (optional) Token Query params
```
```
https://eu.holistics.io/embed/{embed_key_id}/{initial_object}?_token={token}&{query_params}
└──────┬─────┘ └──────┬───────┘ └──┬──┘ └─────┬─────┘
Embed Key Path (optional) Token Query params
```
| Component | Description |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `{initial_object}` | Optional path to control which page opens first |
| `{query_params}` | Optional query parameters for UI customization (see [Query parameters](#query-parameters)) |
### Initial object (URL path)
Deep-link users directly to the content they need (whether it's a specific dashboard, dataset, or the Ask AI page). This lets you create targeted links from emails, notifications, or your application's navigation.
**Supported paths:**
- `/objects/` – Open specific dashboard or dataset
- `/ai` – Open Ask AI page (requires `settings.ai.enabled: true`)
- _(empty)_ – Use default (see resolution order below)
**Examples:**
- Dashboard: `https://{region_domain}.holistics.io/embed/abc123/objects/sales_dashboard?_token=...`
- Ask AI: `https://{region_domain}.holistics.io/embed/abc123/ai?_token=...`
Replace `{region_domain}` with `secure`, `us`, or `eu`. If you use a custom domain, replace the full `{region_domain}.holistics.io` host with your custom domain.
**Resolution order:**
1. URL path (if specified and accessible)
2. `initial_object` in embed definition
3. First object in `objects` list
### Query parameters
Fine-tune the portal's UI behavior with query parameters appended to your embed URL.
| Parameter | Values | Default | Description |
| ---------------------- | ----------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `left_panel_state` | `expanded`, `collapsed` | `collapsed` | Controls the left navigation panel's expand/collapse state |
| `{filter_block_uname}` | Any valid filter value | — | Pre-fill a filter's initial value on the dashboard the URL opens to, using `is`/`equal to`/`matches`-style conditions. Best for setting the dashboard's initial filter state on load. See [Passing Filter Parameters via URL](/docs/url-parameters) for full syntax and [Supported Operators](/docs/url-parameters#supported-operators). |
### URL examples
Here are common scenarios showing how to combine URL paths and query parameters:
| Use case | URL |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| Basic embed | `https://{region_domain}.holistics.io/embed/abc123?_token=...` |
| Open specific dashboard | `https://{region_domain}.holistics.io/embed/abc123/objects/sales_dashboard?_token=...` |
| Open Ask AI page | `https://{region_domain}.holistics.io/embed/abc123/ai?_token=...` |
| Expanded navigation panel | `https://{region_domain}.holistics.io/embed/abc123?_token=...&left_panel_state=expanded` |
| Open dashboard pre-filtered | `https://{region_domain}.holistics.io/embed/abc123/objects/sales_dashboard?_token=...®ion=US` |
## Related documentation
- [Embed Portal](/embedded/embed-portal/) - Overview and getting started guide
- [Row-level Permission](/embedded/embed-portal/permission-settings) - Configure data access
- [Identity & Workspace Settings](/embedded/identity-workspace) - User and organization setup
- [Ask AI](/embedded/ask-ai) - Enable AI features
- [Email Subscriptions](/embedded/email-subscriptions) - Schedule email reports
---
## Row-level permission(Embed-portal)
:::info
This feature is available on select plans. If your team does not have access to this feature, please [contact us](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54).
:::
## Introduction
Holistics supports setting data permissions via row-level permissions to control which data users can access in your embed portals.
**Row-level permissions have two key parts:**
1. **Define permissions based on user attributes** - You specify which dataset fields should be restricted and which user attributes to match against
2. **Control data access through attribute values** - Different users receive different values for these attributes, which determines which specific data they can see
**Example:** A user with `region = 'US'` will only see data where the `region` field equals 'US', while a user with `region = 'EU'` will only see European data.
This approach allows you to create a single embed portal that serves multiple users or organizations while ensuring each user only sees their authorized data.
## How to set up row-level permission in embed portal
Follow these four steps to configure row-level permissions that control which data each user can access:
1. **Define user attributes** in Holistics (e.g., `region`)
2. **Set up permissions** in your dataset to match these attributes with specific fields
3. **Pass user attribute values** from your backend in the embed payload
VIDEO
### 1. Define user attribute
Before using user attributes in dataset permissions, you must define them in Holistics. See: [User Attributes Documentation](/docs/admin/user-attributes)
### 2. Define permission in the dataset
In this step, you need to specify which specific field in the dataset you want to apply the row-level permission on:
```jsx
Dataset sales_data {
// ... your models and dimensions ...
// highlight-start
permission regional_access {
field: r(orders.region) // field in dataset that you want to apply permission
operator: 'matches_user_attribute'
value: 'region' // user attribute
}
// highlight-end
}
```
The code above defines a `regional_access` permission, which ensures that embed users only see data corresponding to their assigned `region` attribute.
**Key points:**
- `field`: reference the field in your dataset that you want to restrict
- `operator`: must be `matches_user_attribute` to define row-level permission based on user attribute
- `value`: user attribute name you defined in the previous step
For more details, please refer to our doc about [**row-level permission as-code**](/docs/access-control/row-level-permission-as-code)
### 3. Pass user attributes from the backend via the [embed payload](/embedded/embed-portal/#step-3-backend-token-generation)
When generating embed tokens, include user-specific attribute values to enforce the row-level permissions you've configured.
```jsx
// Basic embed payload
const embed_payload = {
//highlight-start
user_attributes: {
region: ['US'],
},
//highlight-end
};
```
**Result** Now this embed portal will only show rows which have: `orders.region` is `US`
## Advanced configurations
### Bypass permissions with `__ALL__`
You can bypass specific row-level permissions by setting the user attribute to `__ALL__`:
```jsx
const embed_payload = {
user_attributes: {
region: '__ALL__', // This will let this user view all data regardless of region
company_id: [456], // Still applies company_id restrictions
}
}
```
**Result** the user will see data from all regions.
This will be useful for cases where you want a specific user or a group of users to see all data (like CEO, regional manager, etc.)
### Define default values for user attributes
You can define default user attribute values at the embed portal level to simplify your implementation and reduce repetitive code. This is particularly useful when:
- **All users share common restrictions** - For example, an embed portal where all users should only see US data
- **You want to bypass certain permissions by default** - For example, excluding internal filtering (like `employee_id`) for external users
- **You need fallback values** - Providing sensible defaults that can be overridden when needed
#### Setting default attributes
Define default user attributes directly in your embed portal configuration:
```jsx
EmbedPortal customer_analytics {
objects: [
sales_data, // dataset
sales_performance // dashboard
],
// highlight-start
default_user_attributes {
region: ['US'], // All users see US data by default
employee_id: '__ALL__' // Bypass employee-level filtering by default
}
// highlight-end
}
```
With these defaults:
- All users automatically see US region data
- Employee-level permissions are bypassed (useful for customer-facing portals)
- No need to include these attributes in every embed payload
#### Override behavior
Default values can be overridden by passing specific values in the embed payload. The precedence is:
**Embed payload values > Portal default values**
Example:
```jsx
// Embed portal configuration
EmbedPortal customer_analytics {
objects: [
sales_data,
sales_performance
],
// highlight-start
default_user_attributes: {
region: ['US'],
employee_id: '__ALL__'
}
// highlight-end
}
// Embed payload for a specific user
const embed_payload = {
// highlight-start
user_attributes: {
region: ['EU'] // Override default: this user sees EU data
// employee_id not specified, so uses default '__ALL__'
}
// highlight-end
}
```
**Result**: This user will see EU data (overridden from embed payload) with all employee data (default value in embed portal).
---
## Embed Workers
## Introduction
Holistics' Embedded Analytics feature uses a specific worker type called an **Embed Worker**. These workers' sole usage is to run jobs generated by the Holistics dashboards that you embedded into your websites or applications.
As you scale your business, you will have more simultaneous embedded dashboard viewers. With more Embed Workers, more viewers can be served at the same time without being blocked by other viewers.
:::tip Knowledge Checkpoint
For more details about how Holistics handles concurrent reporting jobs, please refer to the [Job Queue System and Workers](/docs/jobs/queues-and-workers) documentation.
:::
:::info Embed Workers vs. internal workers
- The Embed Workers only impact external-facing embedded dashboards and do not handle internal reporting jobs.
- While users cannot manually configure the number of internal workers, they can freely enable/disable and change the number of embedded workers.
:::
## How to configure Embed Workers
Embed Workers can be toggled and increased from the [Embedded Analytics Manager](https://secure.holistics.io/tools/embed) in the Holistics application.
### Embed Preview Mode
If your subscribed plan offers embedded analytics trial, you’ll see a **Preview Mode** option in Embed Workers dropdown.
This option allows you to trial and test our Embedded Analytics without asking you to buy upfront Embed Workers.
However, under this mode, your embedded dashboards and portals are watermarked, and the embedded performance could be much slower than reality.
## How does caching work in different scenarios with our workers?
Holistics enable caching of query results, reducing the workload on workers when an embedded viewer accesses a previously viewed dashboard.
Let's consider a company 'A' with 2000 viewers (A1, A2,...A2000). The default dashboard filter is uniform for all users within the company, and each user loads 5 widgets.
**Option 1**: Embedding a company-level dashboard (default filter at the company level) without custom viewer filters:
- User A1 facilitates loading dashboards for all based on default filters (triggering cache, where workers' impact is minimal unless viewers apply different filters).
- This serves the 2000 viewers without affecting initial load time, retrieving data directly from the cache.
**Option 2**: Embedding a user-level dashboard (applying filters at the viewer level):
- Holistics dashboard filters apply a "WHERE" clause to each widget's SQL syntax, sending unique queries to the database if not found in the cache.
- Assuming all users view the dashboard simultaneously with different filters, this generates 10000 unique SQL queries (5 reports x 2000 users) for processing.
- Dashboard load time depends on concurrent workers and query runtime.
**Diving into Option 2**, with 5 widgets taking 2 seconds each and 5 concurrent workers:
- Each user loads 5 widgets (5 queries), each taking 2 seconds.
- With 5 workers and 5 widgets, it takes 2 seconds for each user to load a dashboard.
- When all 2000 users view the dashboard simultaneously, it takes roughly 66.6 minutes to load, considering query runtime and concurrent workers.
The decision on load times rests on query runtime and concurrent workers, letting you balance cost vs viewer experience and decide whether to invest in more workers.
---
## Embedded Analytics - FAQs
## I cannot embed Holistics within my localhost site
**Symptoms**: You are trying to embed a Holistics dashboard in your localhost, but receive the following error
* Chrome: `holistics.io refused to connect`
* Firefox: `website will not allow Firefox to display the page` [reference](https://support.mozilla.org/en-US/kb/xframe-neterror-page)
**Explanation**: Holistics has a [CSP (Content Security Policy)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) rule that only allows embedding Holistics dashboards in **HTTPS** websites. This is to ensure your data security.
**Solution**: Run your localhost site with **HTTPS**.
_We at Holistics understand that this is a bit of a hassle, so we are working on a better solution to improve your developer experience._
### InvalidAuthenticityToken error
*Sometimes you'll receive this error:
`ActionController::InvalidAuthenticityToken`*
There are 2 reasons
- Make sure your site has **https**
- Enable 3rd-party cookies in your browsers
### Access denied for localStorage
*Uncaught SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document*
If you have an issue showing the embed, please check the browser's console log for the error:
1. Please open Chrome settings, type "third" in the search box, click
the Content Settings button, and view the fourth item under Cookies.
2. Make sure that the option **Block third-party cookies and site data** is unchecked.
If this setting is checked, third-party scripts cookies are disallowed and access to localStorage may result in thrown SecurityError exceptions.
## I saw a blank page when trying to use the embedded frame on my local PC
Due to our security policy, using HTTPS is enforced to prevent token leakage that might lead to your data leakage. It could be the case that your local environment doesn't set up HTTPS.
To solve this problem, please embed the iframe inside a webpage that is served with HTTPS.
## Would the embedded dashboards be mobile-friendly (responsive) when viewed outside of Holistics?
Yes, we offer a mobile-responsive view for embedded dashboards.
## I received an error: “414 Request-URI Too-Large Error”
This error occurs when the URI exceeds the browser's length limit. In Embedded Analytics, it usually happens if the token settings are too large, such as when using permission rules with many values in the `is` condition.
To resolve this issue, Holistics provides you with an [API to shorten your embed token](/api/v2/reference/embed-shorten-token) as follows:
### Step 1: Obtain a Shortened Token
Submit a POST request to the appropriate URL for [your region's domain](/docs/security-compliance/data-centers#how-do-i-know-which-data-center-im-on):
```
POST https://secure.holistics.io/api/v2/embed/<%= embed_code %>/shorten_token
```
**Request Body:**
```json
{
"token": "<%= token %>"
}
```
**Expected Response:**
```json
{
"shortened_token": "the_shorten_embed_token"
}
```
### Step 2: Use the Shortened Token
Once you have received the `shortened_token`, use it as a regular token in your requests.
```
GET https://{region_domain}.holistics.io/embed/<%= embed_code %>?_token=<%= shortened_token %>
```
Replace `{region_domain}` with `secure`, `us`, or `eu`. If you use a custom domain, replace the full `{region_domain}.holistics.io` host with your custom domain.
## I received an error: "To protect your security, us.holistics.io will not allow Firefox Developer Edition to display the page if another site has embedded it. To see this page, you need to open it in a new window."
Currently, Holistics Embedded Analytics can only be embedded within HTTPS sites to ensure your data security. Therefore, please prepend your address with `https://` or open the embedded dashboard in a new window.
## Why can't I copy values from my embedded dashboard?
This is the default behavior of Holistics, but you can easily configure the embedding code to allow users to copy from your embedded dashboard.
Since Holistics needs to render the dashboards in your websites, there are some default rules applied to prevent Holistics from taking controls of your websites, and "copying" is one of the rules. These rules are implicitly specified by Web Standards to make the web safer.
To enable copying, you need to add `allow="clipboard-read"` to the embedded HTML code of the embedded iframe. Considering the example [here](/embedded/quickstart), the HTML iframe with copying enabled should look like this:
```html
```
---
## Identity & Workspace
:::info
This feature is available on select plans. If your team does not have access to this feature, please [contact us](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54).
:::
## Introduction
Identity & workspace settings enable two critical capabilities for embedded analytics:
* **Create Boundaries for User-Generated Content**: When you enable self-service analytics (users creating their own dashboards), you need clear boundaries:
- **Personal boundaries**: Each user gets their own private space for individual dashboards
- **Organization boundaries**: Team members can collaborate on shared dashboards, but different organizations remain completely isolated
- **Permission boundaries**: Control who can view vs. edit vs. create dashboards in organization's shared workspace
* **Audit & Track User Activity**: When you add `embed_user_id` and `embed_org_id`, Holistics can identify who the user is and log their activities, allowing you to:
- **User identification**: Know which specific user performed each action
- **Organization tracking**: See which organization each user belongs to in the logs
- **Activity logging**: Track user interactions and dashboard creation with proper attribution
:::info Embed Portal only
These identity and workspace features are currently only available with [Embed Portal](/embedded/embed-portal/). Single Dashboard embedding does not support user identity or workspaces yet.
:::
To **enable these capabilities**, you specify user and organization identity in your embed payload:
```js
const embed_payload = {
// identify embed user
"embed_user_id": "user_id_1",
// user "user_id_1" in "organization_id_1" organization
"embed_org_id": "organization_id_1",
// grant them permission -> what they can do
"permissions": {
// specify role of user "user_id_1" in "organization_id_1" shared workspace
"org_workspace_role": "no_access" | "viewer" | "editor"
// allow "user_id_1" to save dashboard to their personal workspace
"enable_personal_workspace": true | false
}
}
```
## Understanding workspaces
Think of workspaces as different rooms where users can work on dashboards:
| | Personal Workspace | Organization Shared Workspace |
| ----------- | -------------------------------------------------------------- | -- |
| **What it is** | A private space where each user creates their own dashboards | A shared space where team members collaborate on dashboards |
| **Who sees it** | Only that specific user | All team members in that organization (with different permission levels) |
| **Best for** | When users need private analytics (like a personal expense tracker) | When teams need to work together on analytics (like a sales team dashboard) |
## Setting up your embed configuration
### Option 1: Individual users only (B2C)
**When to use**: Your app serves individual consumers who need private dashboards
**Example**: A fitness app where each user tracks their personal workout data
```js
const embed_payload = {
"object_name": "embed_portal",
"object_type": "EmbedPortal",
// Tell us who this user is
"embed_user_id": "user_1_id",
// Give them permission to create personal dashboards
"permissions": {
"enable_personal_workspace": true
}
}
```
**Note**: You don't need to specify an organization ID for this setup.
### Option 2: Business users with organizations (B2B)
**When to use**: Your app serves businesses with multiple users who may need to collaborate
#### Setup A: Personal workspaces only
Users get private workspaces but stay isolated within their organization.
```js
const embed_payload = {
"object_name": "embed_portal",
"object_type": "EmbedPortal",
// Tell us who this user is
"embed_user_id": "user_1_id",
// Tell us which organization they belong to
"embed_org_id": "department_1",
// Set their permissions
"permissions": {
// No access to shared team workspace
"org_workspace_role": "no_access", // or "viewer" for read-only
// Allow personal dashboard creation
"enable_personal_workspace": true
}
}
```
**Example use case**: A company app where each employee tracks their individual performance metrics privately.
#### Setup B: Team collaboration
Users can work together on shared dashboards, plus optionally have personal space.
```js
const embed_payload = {
"object_name": "embed_portal",
"object_type": "EmbedPortal",
// Tell us who this user is
"embed_user_id": "team_lead_1",
// Tell us which organization they belong to
"embed_org_id": "department_1",
// Set their permissions
"permissions": {
// Choose their role in the shared workspace
"org_workspace_role": "editor", // or "viewer" or "no_access"
// Optionally allow personal workspace too
"enable_personal_workspace": true // or false
}
}
```
**Example use case**: A project management app where team leads create departmental dashboards that team members can view and collaborate on, while also having personal project tracking spaces.
### Understanding organization roles
- **`no_access`** - Can't see the shared workspace at all
- **`viewer`** - Can view shared dashboards but can't create or edit them
- **`editor`** - Can create, edit, and delete dashboards in the shared workspace
**Real-world example**:
- **Editors**: Team managers who create department dashboards
- **Viewers**: Team members who use those dashboards for their daily work
- **No access**: Users who only need personal analytics
## How user isolation works
**Your data stays secure through workspace isolation**
Personal workspaces are completely separate based on the combination of user ID + organization ID. This means:
- **Same person, different organizations** = **Different personal workspaces**
- **No data mixing** between organizations
- **Clean separation** for security and privacy
### Example scenario
Let's say John works for a company with multiple departments:
```js
// John in the Marketing department
{
embed_user_id: "john@company.com",
embed_org_id: "marketing",
permissions: { enable_personal_workspace: true }
}
// Result: John gets a personal workspace with his marketing dashboards
```
```js
// John moves to the Sales department
{
embed_user_id: "john@company.com",
embed_org_id: "sales",
permissions: { enable_personal_workspace: true }
}
// Result: John gets a NEW personal workspace for sales
// His old marketing dashboards are completely separate and not accessible
```
This isolation ensures that sensitive data from different departments never mix.
---
## Embedded Analytics
With **Embedded Analytics**, you can embed specific dashboards (or a full
analytics portal) directly into your own application. Your users get analytics
that look and feel like a native part of your product, without ever leaving your app.
Under the hood, it works by generating a signed URL that you load in an iframe. The
URL identifies the content, the viewer, and their permissions. Each user only
sees the data they're allowed to.
Think: customer usage dashboards in your SaaS app, per-client reports filtered to
each organization's data, or internal operational dashboards for your team. Here's
what you get out of the box:
- **No build from scratch**: embed pre-built dashboards instead of writing custom analytics code
- **Your app, your brand**: white-label the look, domain, and experience to match your product
- **Secure multi-tenancy**: row-level permissions filter each report to the viewer's own data, mapped to your existing user system
- **Self-service exploration**: let users build their own dashboards and explore data
- **Developer friendly**: define portals as code, with Git integration, sandbox testing, and preview environments
## Get started
Pick the entry point that matches where you are. If you're new, start with the Quickstart.
Spin up a working embed end to end: generate a signed URL and load it in an iframe.
Embed one dashboard for viewing, filtering, and export. The quickest win.
Embed a mini BI app with multiple dashboards and self-service exploration.
## Two approaches to embedding
Holistics offers two ways to embed analytics: **Single Dashboard** and **Embed Portal**.
Single Dashboard
Embed Portal
What it is
Embed a single dashboard
Embed a mini BI app with multiple dashboards.
Setup time
~5 minutes
~15-20 minutes
User can
View, filter, export static dashboards
Basic (single dashboards) + Explore, create, collaborate with others
Best for
Static reporting, quick wins
Self-service analytics, more advanced functionalities
Navigation
Handled by your dev team, more flexible
Provided by embed portal
**Do your users need to create their own dashboards or explore data freely?**
- **Yes** → Use [Embed Portal](/embedded/embed-portal/index.md)
- **No** → Use [Single Dashboard](/embedded/single-dashboard/index.md)
:::tip Not sure which to pick?
Start with **Single Dashboard**. It takes about 5 minutes to set up. You can upgrade to Embed Portal later without losing any work.
:::
## Go further
Once you have a basic embed working, these guides cover production concerns.
How signed URLs, row-level permissions, and multi-tenancy keep each viewer's data isolated.
Map embedded viewers to your existing user system and manage their sessions.
White-label the look, theme, and domain so the embed matches your product.
---
## Embedded: Integrate Into your Application
## Generate Embed Tokens
After generating `permissions`, `filters`, and `settings` you need to generate a token. The payload of the token should contain the **permissions**, **filters**, **settings** (which we had already generated above), and `expired time`, which specifies a time to expire your issued token.
Note that: permissions, filters, and settings must be in JSON type.
Example:
```ruby
# Will expire after 1 day, change it to the value you want.
# Note that expired_time is of type Unix Time. E.g 1498795702
expired_time = Time.now.to_i + 24 * 60
payload = {
settings: settings,
permissions: permissions,
filters: filters,
exp: expired_time
}
```
We use JWT (JSON Web Token) as a mechanism to authenticate the code.
```js
token = JWT.encode(payload, secret_key, 'HS256')
```
You can get the `secret_key` of the embed link by opening Embedded Analytics Sandbox and look for `secret_key` in the `Embed code` section
## Generate Embed URL and integrate into your application
Next, render an iframe pointing to the embed link, with the token baked into it. Holistics then use this token to authenticate and figure out which Customer is logging in, and display your dashboard with only that customer’s data.
Generate embed URL with `embed_code` and `token` (we had already generated `token` above):
```js
holistics_domain = 'secure.holistics.io' // Use us.holistics.io or eu.holistics.io for US/EU accounts
embed_url = 'https://' + holistics_domain + '/embed/' + embed_code + '?_token=' + token
```
You can get `embed_code` in the Embed code section in Embedded Analytics Sandbox.
Finally, simply include an iframe with that URL in your application:
```html
```
---
## Managing user-created dashboards
:::info
This feature is available on select plans. If your team does not have access to this feature, please [contact us](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54).
:::
## Introduction
When you enable [user-built dashboards](/embedded/user-built-dashboards) for your embed users, their dashboards are stored in your Development environment as code files. This guide helps administrators view, monitor, and manage these dashboards.

:::info Setup required
This feature requires Git integration and workspace configuration. See [user-built dashboards](/embedded/user-built-dashboards) for setup instructions.
:::
## Managing user dashboards
### Accessing user dashboard files
To find user dashboards in your codebase, open AML Studio in your Development environment and navigate to `modules/embedding/`. From there, browse to the relevant organization and workspace folder, then click any `.page.aml` file to view its contents.
For details on the folder structure, see [How user dashboards are stored](#how-user-dashboards-are-stored).
### What admins can do
Admins have full visibility into user dashboard code. You can view and edit file contents like dashboard titles, descriptions, settings, visualizations, and layouts. You can also update dataset and field references when modeling changes break dashboards.
### What admins cannot do
Some operations are restricted because user dashboard ownership and metadata are managed by the system, not the files themselves. You cannot create, rename, move, or delete user dashboard files through the UI. You also cannot modify the folder organization or file names. If you manually create new files, they won't appear in the user's workspace.
### Common scenarios
**Fixing broken dashboards after model changes**
When you refactor your data models (rename fields, move datasets), user dashboards referencing those objects may break. To fix them, navigate to the affected dashboard file, update the dataset or field references to match your new model structure, then save your changes.
**Reviewing what users have built**
To understand how embed users are using your data, browse the `shared_workspace` folders to see dashboards shared within organizations, and check `personal_workspace` folders to see individual user dashboards.
## How user dashboards are stored
This section explains the folder structure and file organization for reference.
### Folder structure overview
User dashboards are organized within the `modules/embedding/` directory of your AML codebase:
```
📦 AML Studio
┗ 📂 modules/
┗ 📂 embedding/
┣ 📂 org_${embed_org_id}/
┃ ┣ 📂 shared_workspace/
┃ ┃ ┗ 📜 ${uname_with_postfix}.page.aml
┃ ┗ 📂 personal_workspace/
┃ ┗ 📂 user_${embed_user_id}/
┃ ┗ 📜 ${uname_with_postfix}.page.aml
┣ 📂 org__DEFAULT_ORG_
┃ ┗ 📂 personal_workspace/
┃ ┗ 📂 user_${embed_user_id}/
┗ 📂 org_${another_org_id}/
┣ 📂 shared_workspace/
┗ 📂 personal_workspace/
```
Example:

### Workspace types
Each organization can have two types of workspaces where user dashboards are stored.
**Shared workspace** is located at `modules/embedding/org_${embed_org_id}/shared_workspace/`. It contains dashboards created by users with the [editor role](/embedded/identity-workspace#understanding-organization-roles) in the organization's shared workspace. This folder is only created when editors actually create dashboards.
**Personal workspace** is located at `modules/embedding/org_${embed_org_id}/personal_workspace/user_${embed_user_id}/`. It contains personal dashboards belonging to a specific user within the organization. This folder is only created when personal workspace is enabled and users actually create dashboards.
### Default organization
When `embed_org_id` is empty, personal dashboards are stored in `modules/embedding/org__DEFAULT_ORG_/personal_workspace/user_${embed_user_id}/`. This special organization only supports personal workspace with no shared workspace available.
:::info Why are personal workspaces nested under organizations?
Users belong to organizations, so personal workspace folders are nested within the organization structure. This ensures proper data isolation between different organizations. For detailed security architecture, see [How user isolation works](/embedded/identity-workspace#how-user-isolation-works).
:::
### File naming conventions
User dashboard files follow a consistent naming pattern. The format is `${uname_with_postfix}.page.aml`, where the filename is automatically generated from the dashboard title plus a unique string. For example, a dashboard titled "Sales Report" becomes `sales_report_a1b2c3.page.aml`.
## Related resources
- [User-built dashboards](/embedded/user-built-dashboards) - Enable and configure the feature
- [Identity and workspace settings](/embedded/identity-workspace) - Configure user roles and workspace permissions
- [How user isolation works](/embedded/identity-workspace#how-user-isolation-works) - Understand the security model
---
## Multi-environment
## Introduction
Managing analytics across development, testing, and production (or across different regions) can be challenging when you need consistent code but isolated data. A multi-environment setup solves this by letting you run multiple Holistics accounts, each connected to its own data
source, while keeping your analytics code synchronized through Git.
**Use case 1 - Development pipeline**: Develop analytics code in a dev environment, test it in UAT, then deploy to production for end-users. Changes get thoroughly tested against isolated
data before reaching end-users, reducing the risk of production errors.
**Use case 2 - Regional compliance**: Keep data isolated across regions (e.g., APAC, EU, US)
while using identical analytics code everywhere.

## High-level concept
In a multi-environment setup, each stage of your deployment process exists as a separate Holistics account (e.g., `dev-your-company.holistics.io`, `prod-your-company.holistics.io`).
- **Shared codebase**: All environments connect to the same GitHub/Gitlab repository for code sharing and syncing.
- **Data isolation**: Each environment connects to its own dedicated data source (e.g., Dev account connects to `dev_db`, Prod account connects to `prod_db`).
- **Consistency via naming**: By keeping data source names identical across accounts, both accounts call their connection dw_main), the code remains portable without modification.
## Implementation guide
### Step 1: Initial account configuration
- Log into each Holistics account, connect your Github/Gitlab repository
- Setup Data Sources: Create data sources using **identical names** across all accounts.
### Step 2: The development workflow (in QA/DEV)
- Create a new branch in the Development tab e.g. `feature/mrr-dashboard`
- Develop your AML codes such as models, datasets, dashboards, etc.
- Commit and Publish
### Step 3: Promotion to production
Once the dashboard is verified in the Dev environment:
- Log in to the **Production Environment** (e.g., `prod-your-company.holistics.io`).
- Choose a pre-release branch e.g. `pre-release`
- Pull Changes: Click **Pull from Production** to merge the changes from `master` branch to the `pre-release` branch
- Finally, publish the changes to production
## Constraints
### What does NOT sync?
Only items defined "as-code" in the Development tab (AML files) are synced via Git. The following items must be configured manually in each environment's Reporting tab:
- **Permissions**: Sharing settings for folders and dashboards.
- **Schedules**: Email/Slack data deliveries and alerts.
- **Dashboard metadata**: Folder structures within the Reporting tab.
### Constraints on naming rules
Avoid renaming a dashboard/dataset in the Development (AML) code once it has been published.
Holistics maps the code-name to a Reporting ID internally. If you change dashboard `old_name` to dashboard `new_name`:
- Holistics treats this as deleting the old dashboard and creating a new one.
- Result: All existing schedules, permissions, and Embed URLs associated with that dashboard will be deleted.
---
## Quickstart(Embedded)
In this guide, you'll embed a live Holistics dashboard into your web app using **Single Dashboard** embedding (the fastest way to get started). Not sure which approach fits your use case? See [Two approaches to embedding](/embedded#two-approaches-to-embedding).
## Prerequisites
Before you begin, make sure you have:
- A Holistics account with at least one dashboard built
- A web app to embed into (any stack: Rails, Express, Django, etc.)
## Enable Embedded Analytics
By default, Embedded Analytics is not enabled. To turn it on, go to **Tools → Embedded Analytics** and enable the features you need:
- **Basic features**: [Single Dashboard embedding](/embedded/single-dashboard/) for quick, targeted dashboard integration
- **Advanced features**: [Embed Portal](/embedded/embed-portal/) for full self-service analytics capabilities
Each feature can be set to **Off**, **Trial**, or **On**. Enable [Trial mode](/embedded/embed-workers#embed-preview-mode) to try things out without worrying about cost changes. You can also configure the number of [embed workers](/embedded/embed-workers) to handle your embedded content load.
## Embed a single dashboard
The whole flow boils down to three steps: get your credentials from the Holistics UI, generate a signed token on your backend, and drop an iframe into your frontend.
### Step 1: Generate an embed link
1. Open the dashboard you want to embed
2. Go to **Settings → Dashboard Preferences → Embedded Analytics**
3. Click **Generate Embed Link**
You'll get two values:
- **Embed Code**: identifies which dashboard to show
- **Secret Key**: used to sign the embed token (keep this safe on your backend!)
### Step 2: Generate a token (backend)
On your server, build a JWT payload with basic settings and sign it with your secret key. Here's a minimal example: no permissions or filters, just enough to get the dashboard on screen.
```javascript
const jwt = require('jsonwebtoken');
const embedCode = '275XXXXXXXXX'; // from Step 1
const secretKey = '382fXXXXXXXX'; // from Step 1
const payload = {
settings: {
enable_export_data: false,
},
permissions: {
row_based: [],
},
filters: {},
exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60, // expires in 24 hours
};
const token = jwt.sign(payload, secretKey, { algorithm: 'HS256' });
```
```python
embed_code = '275XXXXXXXXX' # from Step 1
secret_key = '382fXXXXXXXX' # from Step 1
payload = {
'settings': {
'enable_export_data': False,
},
'permissions': {
'row_based': [],
},
'filters': {},
'exp': int(time.time()) + 24 * 60 * 60, # expires in 24 hours
}
token = jwt.encode(payload, secret_key, algorithm='HS256')
```
```ruby
require 'jwt'
embed_code = '275XXXXXXXXX' # from Step 1
secret_key = '382fXXXXXXXX' # from Step 1
payload = {
settings: {
enable_export_data: false,
},
permissions: {
row_based: [],
},
filters: {},
exp: Time.now.to_i + 24 * 60 * 60, # expires in 24 hours
}
token = JWT.encode(payload, secret_key, 'HS256')
```
### Step 3: Add the iframe (frontend)
Drop this iframe into your HTML, substituting in the `embed_code`, `token`, and region domain from your backend:
```html
```
Replace `{region_domain}` with `secure`, `us`, or `eu`. If you use a custom domain, replace the full `{region_domain}.holistics.io` host with your custom domain.
That's it! You should see your dashboard rendered inside your app.
Once it's working, you can fine-tune things like permissions, filters, and appearance:
- [Basic Settings](/embedded/single-dashboard/basic-settings): control export, timezone, header visibility, and filter defaults
- [Permission Settings](/embedded/single-dashboard/permission-settings): restrict data access per user with row-level permissions
- [Drill-Through](/embedded/single-dashboard/drill-through-embed): let users click into related dashboards
## Ready for more? Try Embed Portal
Single Dashboard embedding is great for targeted, read-only views. But when you need **multi-dashboard navigation**, **self-service exploration**, or **multi-tenant workspaces**, Embed Portal gives your users a full analytics experience inside your app.
Here's what the setup looks like at a high level:
### Step 1: Enable Git flow
Embed Portal uses an as-code workflow, so you'll need Git integration enabled. Follow the [Version Control with Git](/docs/git-version-control) guide to set that up.
### Step 2: Define your portal
Create a `.embed.aml` file in your Development tab that lists the dashboards and datasets you want to expose:
```aml title="my_portal.embed.aml"
EmbedPortal my_portal {
objects: [
sales_overview, // dashboard
revenue_trends, // dashboard
sales_data, // dataset for self-service exploration
]
}
```
### Step 3: Generate credentials and embed
The backend/frontend pattern is similar. Generate a JWT token and load it in an iframe. The key difference is that the payload references your portal object instead of a single dashboard.
For the full walkthrough (including preview, publishing, and credential generation), head to the **[Embed Portal](/embedded/embed-portal/#getting-started)** guide.
## Next steps
- [Single Dashboard Settings](/embedded/single-dashboard/basic-settings): customize dashboard behavior and appearance
- [Embed Portal Deep Dive](/embedded/embed-portal/): full guide for multi-dashboard self-service embedding
- [Security](/embedded/security): understand token expiry, data isolation, and best practices
- [FAQs](/embedded/faqs): common questions and troubleshooting tips
---
## Show different dashboards to different users
## Introduction
Control which dashboards each user sees based on their role. On a marketplace platform, sellers see sales and inventory dashboards, buyers see order history and spending analytics, and admins get a platform-wide overview. Each sees only what's relevant to them.
| Role | What they see |
|------|---------------|
| Sellers | Sales Performance, Inventory, Reviews |
| Buyers | Order History, Spending Analytics |
| Admins | Platform Overview, Seller Metrics |
This is different from [row-level permissions](/embedded/embed-portal/permission-settings), which control *what data* a user sees within a dashboard. Role-based access controls *which dashboards* they see in the first place.
## How it works
1. **Create a portal for each role**: Each portal contains only the dashboards that role needs
2. **Your backend selects the portal**: When generating the embed token, set `object_name` to the appropriate portal based on the user's role
3. **Users see their dashboards**: The embedded portal shows only the dashboards you've assigned
```
User logs in → Backend checks role → Selects portal → User sees their dashboards
```
## Step-by-step implementation
### Step 1: Define a portal for each role
Create a separate `.embed.aml` file for each role. Each portal includes only the dashboards and datasets that role needs. (See [Define your portal](/embedded/embed-portal/#step-1-define-your-portal) for the basics.)
```aml title="seller_portal.embed.aml"
EmbedPortal seller_portal {
objects: [
sales_performance, // dashboard
inventory_status, // dashboard
seller_data, // dataset
]
}
```
```aml title="buyer_portal.embed.aml"
EmbedPortal buyer_portal {
objects: [
order_history, // dashboard
spending_analytics, // dashboard
buyer_data, // dataset
]
}
```
**A few things to note:**
- Dashboards can appear in multiple portals. For example, a "Reviews" dashboard could be included in both `seller_portal` and `buyer_portal`.
- You need to publish all portals to production for them to be available for embedding. See [Publishing to production](/docs/development/dev-prod-mode#publishing-to-production-go-live).
### Step 2: Select the portal in your backend
In your backend, map each user role to the corresponding portal name, and use that when [generating the embed token](/embedded/embed-portal/#step-3-backend-token-generation).
```javascript
// Map roles to portal names
const ROLE_PORTAL_MAP = {
seller: 'seller_portal',
buyer: 'buyer_portal',
admin: 'admin_portal',
};
function generateEmbedUrl(user) {
const portalName = ROLE_PORTAL_MAP[user.role];
const regionDomain = 'secure'; // Use 'us' or 'eu' for US/EU accounts. For custom domains, replace the full host.
const embed_payload = {
// highlight-start
object_name: portalName, // This selects which portal the user sees
// highlight-end
object_type: 'EmbedPortal',
embed_user_id: user.id,
embed_org_id: user.orgId,
exp: Math.floor(Date.now() / 1000) + 3600,
};
const token = jwt.sign(embed_payload, EMBED_SECRET, { algorithm: 'HS256' });
return `https://${regionDomain}.holistics.io/embed/${EMBED_KEY_ID}?_token=${token}`;
}
```
:::info
Portal selection happens inside the [embed payload](/embedded/embed-portal/#step-3-backend-token-generation), which is [encrypted](/embedded/security#why-json-web-token-jwt). Users can't switch to a different portal by modifying the URL, they only see what your backend assigns to them.
:::
### Step 3: Embed in your frontend
The frontend code is exactly the same as a single-portal setup. No changes needed. The portal selection is entirely handled by the backend.
```html
```
## Combining with row-level permissions
Role-based portal access and row-level permissions serve different purposes, and you can use them together for fine-grained control.
| Layer | What it controls | How it works |
|-------|-----------------|--------------|
| **Portal selection** (`object_name`) | Which dashboards the user can access | Backend maps role → portal name |
| **Row-level permissions** (`user_attributes`) | Which data rows the user can see within those dashboards | User attributes filter query results |
For example, sellers should see the seller portal, but each seller should only see their own sales data. Here's how you'd combine both:
```javascript
function generateEmbedUrl(user) {
const portalName = ROLE_PORTAL_MAP[user.role];
const regionDomain = 'secure'; // Use 'us' or 'eu' for US/EU accounts. For custom domains, replace the full host.
const embed_payload = {
// highlight-start
object_name: portalName, // Controls which dashboards they see
// highlight-end
object_type: 'EmbedPortal',
embed_user_id: user.id,
embed_org_id: user.orgId,
// highlight-start
user_attributes: {
seller_id: [user.sellerId], // Controls which data they see
},
// highlight-end
exp: Math.floor(Date.now() / 1000) + 3600,
};
const token = jwt.sign(embed_payload, EMBED_SECRET, { algorithm: 'HS256' });
return `https://${regionDomain}.holistics.io/embed/${EMBED_KEY_ID}?_token=${token}`;
}
```
For more on setting up row-level permissions, see the [Row-level Permission guide](/embedded/embed-portal/permission-settings).
## Example: SaaS with tiered plans
The same technique works for subscription-based access. Create a portal for each tier (Basic, Pro, Enterprise) and select based on the user's plan instead of their role.
For a complete guide on monetizing analytics with subscription tiers (including how to gate features like data exploration and Ask AI), see [SaaS Tiered Plans](/embedded/saas-tiered-plans).
## Best practices
- **Combine with row-level permissions**: Portal selection controls which dashboards a user sees, while row-level permissions control which data they see. Use both together for the most secure setup.
- **Name portals descriptively**: Use names like `seller_portal` or `buyer_portal` rather than `portal_1` or `portal_a`. It makes your code and AML definitions easier to maintain.
- **Test each portal**: Preview each portal in the Holistics development environment before publishing. Make sure every role sees the right set of dashboards.
- **Reuse dashboards across portals**: A dashboard can belong to multiple portals. You don't need to duplicate dashboards just because two roles need to see the same one.
---
## Monetize Analytics with Subscription Tiers
## Introduction
Turn your embedded analytics into a revenue driver by offering different capabilities at each subscription tier. Basic users get view-only dashboards, Pro users can explore and customize, and Enterprise users get AI-powered insights.
| Plan | Capabilities |
|------|--------------|
| **Basic** | View dashboards |
| **Pro** | View + [Explore data](/embedded/self-serve-exploration) |
| **Enterprise** | View + Explore + [Ask AI](/embedded/ask-ai) |
This approach lets you:
- **Create upsell opportunities**: Users see the value of higher tiers through feature limits
- **Match pricing to value**: Charge more for self-service exploration and AI
- **Reduce support load**: Higher-tier users answer their own questions
## How it works
You control capabilities through two mechanisms:
1. **Portal contents**: Including datasets enables exploration; omitting them creates a view-only experience
2. **Embed payload settings**: The `settings.ai.enabled` flag controls Ask AI access
```
Basic: Portal (dashboards only) → View only
Pro: Portal (dashboards + datasets) → View + Explore
Enterprise: Portal (dashboards + datasets) + AI enabled → View + Explore + Ask AI
```
## Step-by-step implementation
### Step 1: Create a portal for each tier
Each tier gets its own portal. The key difference is whether you include datasets.
```aml title="basic_portal.embed.aml"
EmbedPortal basic_portal {
objects: [
overview_dashboard,
// No datasets = view-only experience
]
}
```
```aml title="pro_portal.embed.aml"
EmbedPortal pro_portal {
objects: [
overview_dashboard,
sales_analytics,
customer_insights,
// highlight-start
sales_data, // Dataset enables exploration
// highlight-end
]
}
```
```aml title="enterprise_portal.embed.aml"
EmbedPortal enterprise_portal {
objects: [
overview_dashboard,
sales_analytics,
customer_insights,
executive_summary,
// highlight-start
sales_data, // Dataset enables exploration
operations_data, // Dataset enables exploration
// highlight-end
]
}
```
**Why datasets matter:**
- Without datasets, users can only view pre-built dashboards
- With datasets, users can click into charts, change groupings, add filters, and save custom views
- Ask AI also requires datasets to answer questions
### Step 2: Select portal and features in your backend
Map each subscription plan to its portal, and enable AI for Enterprise users.
```javascript
const PLAN_PORTAL_MAP = {
basic: 'basic_portal',
pro: 'pro_portal',
enterprise: 'enterprise_portal',
};
function generateEmbedUrl(user) {
const regionDomain = 'secure'; // Use 'us' or 'eu' for US/EU accounts. For custom domains, replace the full host.
const embed_payload = {
object_name: PLAN_PORTAL_MAP[user.plan],
object_type: 'EmbedPortal',
embed_user_id: user.id,
embed_org_id: user.orgId,
// highlight-start
settings: {
ai: {
enabled: user.plan === 'enterprise', // Only Enterprise gets Ask AI
}
},
// highlight-end
exp: Math.floor(Date.now() / 1000) + 3600,
};
const token = jwt.sign(embed_payload, EMBED_SECRET, { algorithm: 'HS256' });
return `https://${regionDomain}.holistics.io/embed/${EMBED_KEY_ID}?_token=${token}`;
}
```
### Step 3: Embed in your frontend
The frontend code is the same regardless of tier. All the logic is in your backend.
```html
```
## Multi-tenant data isolation
In a SaaS application, you'll also want each customer to see only their own data. Combine portal selection with [row-level permissions](/embedded/embed-portal/permission-settings) using `user_attributes`.
```javascript
function generateEmbedUrl(user) {
const regionDomain = 'secure'; // Use 'us' or 'eu' for US/EU accounts. For custom domains, replace the full host.
const embed_payload = {
object_name: PLAN_PORTAL_MAP[user.plan],
object_type: 'EmbedPortal',
embed_user_id: user.id,
embed_org_id: user.orgId,
// highlight-start
user_attributes: {
client_id: [user.clientId], // Each client sees only their data
},
// highlight-end
settings: {
ai: {
enabled: user.plan === 'enterprise',
}
},
exp: Math.floor(Date.now() / 1000) + 3600,
};
const token = jwt.sign(embed_payload, EMBED_SECRET, { algorithm: 'HS256' });
return `https://${regionDomain}.holistics.io/embed/${EMBED_KEY_ID}?_token=${token}`;
}
```
This ensures:
- **Portal selection** controls which dashboards and features they get (based on subscription)
- **Row-level permissions** controls which data they see (based on their client/tenant ID)
For setup details, see the [Row-level Permission guide](/embedded/embed-portal/permission-settings).
## Additional features by tier
Beyond the core view/explore/AI progression, you can gate other features:
| Feature | How to enable | Typical tier |
|---------|---------------|--------------|
| [Personal workspace](/embedded/identity-workspace#option-1-individual-users-only-b2c) | `permissions.enable_personal_workspace: true` | Pro+ |
| [Shared workspace](/embedded/identity-workspace#option-2-business-users-with-organizations-b2b) | `permissions.org_workspace_role: 'editor'` | Enterprise |
| [Email subscriptions](/embedded/email-subscriptions) | `settings.allow_data_subscribe: true` | Pro+ |
| [Data export](/embedded/embed-portal/parameters-reference) | `permissions.allow_dashboard_export: true` | Pro+ |
```javascript
function generateEmbedUrl(user) {
const regionDomain = 'secure'; // Use 'us' or 'eu' for US/EU accounts. For custom domains, replace the full host.
const embed_payload = {
object_name: PLAN_PORTAL_MAP[user.plan],
object_type: 'EmbedPortal',
embed_user_id: user.id,
embed_org_id: user.orgId,
// highlight-start
permissions: {
enable_personal_workspace: user.plan !== 'basic',
org_workspace_role: user.plan === 'enterprise' ? 'editor' : null,
allow_dashboard_export: user.plan !== 'basic',
},
settings: {
ai: { enabled: user.plan === 'enterprise' },
allow_data_subscribe: user.plan !== 'basic',
},
// highlight-end
exp: Math.floor(Date.now() / 1000) + 3600,
};
const token = jwt.sign(embed_payload, EMBED_SECRET, { algorithm: 'HS256' });
return `https://${regionDomain}.holistics.io/embed/${EMBED_KEY_ID}?_token=${token}`;
}
```
## Related guides
- [Role-based portal access](/embedded/role-based-portal-access): Show different dashboards to different user roles
- [Self-serve exploration](/embedded/self-serve-exploration): Enable data exploration features
- [Ask AI](/embedded/ask-ai): Set up AI-powered analytics
- [Parameters reference](/embedded/embed-portal/parameters-reference): Full list of embed payload options
---
## Embedded Analytics - Security Matters
## HTTPs
For security purposes, it is recommended to use HTTPS when embedding Holistics on your side.
## Secret Key
The key we issue you in step 2 is to sign your payload with **HMAC 256** signature mechanism. This signature is for us to check the payload's integrity and prevent people from tampering with and modifying your payload during the request.
## Authentication
### Mechanism
We use **JSON Web Token (JWT)** for user authentication. This is how it works:
- On the front-end side, Holistics dashboard is embedded into your website or application using **iframe**. The source link of the iframe contains an identification code (`embed_code`) of the dashboard, and a `token` which defines how your users can see the dashboard.
- The `token` is generated using JWT, from a payload containing your user's login name, dashboard settings, user permissions, filters, expired time... and the `secret_key` obtained when you enable embedding for your dashboard.
- Holistics then use this `token` to authenticate and figure out which user is logging in, and display your dashboard with only that customer's data.
### Why JSON Web Token (JWT)?
Our Embedded feature is designed for **multi-tenancy use case**, where our customers want to embed a dashboard into their own application, and when their users log in they will see different data depending on their permissions.
With a token generated with JWT, you can identify your users and ensure they can only see the data they are allowed to. Users cannot simply change URL parameters to pull any data points they want, since their permission is baked into the token. They also cannot tamper with the token's content, since Holistics can verify the integrity of the content and disregard tampered tokens.
### Token Expiration
You must specify a time to expire your issued JWT. The recommended expiration time is 24 hours after you issue the token.
This is a precaution against the situation when someone steals your user's token (which is not difficult to do), uses it to assume your user's identity, and accesses their data illegally.
When the token has a short expiration time, the damage is minimized.
## Sensitive Data
Note that the JWT only allows us to check the integrity of the received payload. **No cryptography is involved in JWT**, which means your payload's information is not concealed from others.
:::danger
Please do not include any sensitive data (for example, user's password) inside the payload.
:::
## Enforcing Data Access Control the right way
Although it seems that both [**Permission Settings**](/embedded/single-dashboard/permission-settings) and [**Control Settings**](/embedded/single-dashboard/basic-settings#control-settings) can restrict user's access to data, in reality, they serve two different purposes.
Here is the general rule of thumb to help you decide which one to use:
- **Permission Settings** should be used if you want to **enforce data access restriction** on your embedded analytics viewers so that they can only view a subset of your data.
:::tip Notes
**Permission Settings** should always be used for security best practices.
:::
- **Control Settings** is a convenient method to set up default values for your embedded dashboard. It helps you control what data is initially displayed when the dashboard loads. For example, if a user clicks the "Europe" button in your application, you can set the region filter to default to "Europe". This way, they see Europe data in the dashboard first, but can still change to other values when interacting with the dashboard.
:::danger Important
Although we do not provide the UI for the embedded viewers to modify these settings' values, it is still **possible for users to tamper with these values** on their ends.
:::
The differences between the two settings are summarized below.
| | **Permission Settings** | **Control Settings**|
|-----|-----------------|----------------------|
| **Purpose** | Data Access Control | Override filter default values |
| **Data Restriction Level** | Dataset level (Strict) | UI only (Can be tampered)|
Let's demonstrate their key differences by inspecting a dashboard with multiple country values as shown in the example below. If you set the **Permission Setting** for `country_name` to Vietnam, your country filter will only show the value Vietnam. It will return no value if you try to change the filter value to another value other than Vietnam.
By contrast, if you pick **Control Settings** to restrict access, clever users can tamper with the `country_name` and can access unauthorized data from other countries.
---
## User-built dashboards
:::info
This feature is available on select plans. If your team does not have access to this feature, please [contact us](https://form.jotform.com/230511857392457/prefill/684bfbbc30393001e0ede1234b54).
:::
## Introduction
Self-serve dashboard creation **lets your embed users to build, save, and manage their own dashboards** within your applications. Instead of waiting for your team to create every report, users can explore data and save their insights independently.

## How it works
1. Embed users explore data using [interactive features](/embedded/self-serve-exploration) (data exploration, drill down, etc.)
2. When they want to keep their work, they save it to a dashboard
3. Dashboards are stored in their personal or shared workspace
:::info Managing user dashboards
Behind the scenes, these dashboards are saved as code files in your Git repository. Administrators can view, audit, or manage these user-generated dashboards. For detailed instructions, see our guide on [managing user dashboards](/embedded/manage-user-dashboards).
:::
## Setup
:::info Embed Portal only
This feature is only available with [Embed Portal](/embedded/embed-portal/). It is not supported in Single Dashboard embedding.
:::
### Step 1: Enable Git integration
User dashboards are saved as code files in your Git repository. To set this up:
1. [Enable explicit Git integration](/docs/git-version-control) in Holistics
2. If using external Git, ensure write access and disable protected branch restrictions for the branch where dashboards are stored
### Step 2: Include datasets in your Embed Portal
Users can only build dashboards from datasets included in your portal. To define what they can explore and build from, add the relevant datasets to your Embed Portal:
```javascript title="your_embed_portal.embed.aml"
EmbedPortal ecommerce_portal {
objects: [
sales_dashboard, // Dashboard users can view
sales_dataset, // Dataset users can explore and build from
products_dataset, // Another dataset for self-serve
],
}
```
### Step 3: Configure user workspaces
Embed users need somewhere to save their dashboards. You can give them a personal workspace, access to a shared workspace, or both.
#### Personal workspace
A private space where each user creates dashboards only they can see.
**Best for:** B2C applications, individual analytics, personal trackers.
```js
const embed_payload = {
"embed_user_id": "user_123",
"permissions": {
"enable_personal_workspace": true
}
}
```

#### Shared workspace
A collaborative space where team members can view and edit dashboards together.
**Best for:** B2B applications, team analytics, department dashboards.
```js
const embed_payload = {
"embed_user_id": "user_123",
"embed_org_id": "team_alpha",
"permissions": {
"org_workspace_role": "editor" // or "viewer" for read-only
}
}
```

#### Combining both
Users can have access to both personal and shared workspaces:
```js
const embed_payload = {
"embed_user_id": "user_123",
"embed_org_id": "team_alpha",
"permissions": {
"enable_personal_workspace": true,
"org_workspace_role": "editor"
}
}
```

For detailed configuration options and user isolation, see [Identity & Workspace Settings](/embedded/identity-workspace).
## What users can do
Once configured, your users' capabilities are defined by their workspace access and assigned roles:
| Action scope | Personal workspace | Shared workspace (Viewer) | Shared workspace (Editor) |
| :--- | :---: | :---: | :---: |
| **PERSONAL WORKSPACE** | | | |
| Create, edit, delete dashboards | ✅ | — | — |
| **SHARED WORKSPACE** | | | |
| View dashboards | — | ✅ | ✅ |
| Create, edit, delete dashboards | — | ❌ | ✅ |
For detailed configuration options and user isolation, see [Identity & Workspace Settings](/embedded/identity-workspace).
## Related resources
- [Identity & Workspace Settings](/embedded/identity-workspace) - Deep dive on permissions and user isolation
- [Self-Serve Exploration](/embedded/self-serve-exploration) - Enable data exploration features
- [Managing User Dashboards](/embedded/manage-user-dashboards) - Admin guide for viewing and managing user-created dashboards
---
## Basic settings
## Introduction
This page covers the basic settings you can configure through the **Embedded Analytics Sandbox**: things like export permissions, timezone, header visibility, and filter defaults.
## General settings
The **General Settings** section control some general aspects of the embedded dashboard, including:
- Allowing users to export the dashboard or not
- Allowing users to export raw data or not
- Showing or hiding the dashboard header panel
- Showing or hiding the dashboard control panel
- Enabling auto-run on changes
When you toggle these options on or off, the generated embedding script updates accordingly.
### Auto-run on changes
By default, the embedded dashboard waits for viewers to click **Apply** after changing a filter. Set `dashboard_autorun_on_changes: true` in your JWT payload to re-run the dashboard automatically on every filter or control change:
```js
const payload = {
settings: {
dashboard_autorun_on_changes: true,
},
// ...rest of payload
};
```
This overrides the dashboard's internal [auto-run on changes](/docs/dashboards/settings#auto-run-on-changes) setting. The default is `false`.
> **Note:** Auto-run fires a query on every filter change. For dashboards with many charts or heavy queries, leaving this off avoids unexpected query costs.
## Timezone settings
Similar to normal dashboards, you can set the default timezone for an embedded dashboard and allow viewers to change the timezone:
For more details, see [Timezone Settings](/docs/datetimes/timezones).
## Control settings
:::danger This is not a data security feature
Overriding filter default values is only a convenience feature. Your users can still change filter values freely on the embedded dashboard. **No data restriction is enforced**.
To enforce what data your users can access, use [**Permission Settings**](/embedded/single-dashboard/permission-settings) instead. See the [Security](/embedded/security#enforcing-data-access-control-the-right-way) documentation for more details on the difference.
:::
The **Control Settings** section lets you control what data is initially displayed by overriding the default settings of your dashboard filters. This is useful when you want to:
- **Show contextual data on load:** For example, if a user clicks "Europe" in your application, you can set the region filter to default to "Europe". That way, they see relevant data right away.
- **Improve performance:** By narrowing default filter values, you reduce the amount of data loaded initially, resulting in faster dashboard load times.
When you change the default values and operators in the graphical filter list, your choices are reflected in the embedding code.
The code representation of each control setting has the following form:
```js
filters = {
"filter_uname": {
"default_condition": {
"operator": "expected_operator",
"values": [
"expected_value"
],
"modifier": null
}
},
}
```
**Explanations**:
- **filter_uname:** defines which filter you want to override in the embedded dashboard. This value is auto-generated.
- **default_condition:** contains the default configuration for this filter, including:
- **operator:** the operator used in the filter (e.g., `matches`, `is`, `is not`). See [Supported Field Types and Operators](/docs/filters/#supported-field-types-and-operators) for the full list.
- **values:** the values you want to apply to this filter in the embedded dashboard.
- **modifier (optional):** only available for some operators in the [Date filter](/docs/filters/date-filters#introduction) (e.g., `next`/`last` X days/months/years). The modifier value specifies the time unit, such as `day`, `month`, or `year`.
**Examples:**
Set the default value of date filter `f1` to "*last 6 months*" (including the current period):
```js
"f1": {
"default_condition": {
"operator": "last",
"values": ["6"],
"modifier": "month",
"options": {
"include_current_period": true
}
}
}
```
Set the default value of field filter `f3` to "*is Asia*":
```js
"f3": {
"default_condition": {
"operator": "is",
"values": ["Asia"],
"modifier": null
}
}
```
---
## Drill-through(Single-dashboard)
:::tip Knowledge checkpoint
This documentation assumes you're familiar with:
- [Drill-Through](/docs/interactions/drill-through)
- [How to embed your Holistics dashboard](/embedded)
:::
## Introduction
**Drill-through** lets your embedded users dig deeper, explore related data, and generate more business insights. Drill-through in embedded dashboards works exactly like it does in normal dashboards, with a few additional setup steps.
## How to set up drill-through in embedded dashboards
:::info **Prerequisite**
You must have already set up a drill-through for the dashboard inside Holistics.
:::
### Add `drillthroughs` to the payload
To enable drill-through in your embedded dashboard, add the `drillthroughs` value to the payload (alongside other settings like `permissions` or `filters`).
```ruby
payload = {
settings: settings,
permissions: permissions,
filters: filters,
drillthroughs: drillthroughs,
exp: expired_time
}
```
The `drillthroughs` value contains the ID(s) of the dashboards that you allow your embedded users to drill into.
### Add destination dashboard_id to the `drillthroughs`
:::info Notes
Your dashboard inside Holistics may drill to multiple dashboards. To control which ones your embedded users can access, you need to **explicitly** add each destination dashboard's ID to the `drillthroughs` value.
:::
For example, your dashboard **A inside Holistics** can drill to dashboard **B, C, D, E** with id **123, 456, 789, and 111** respectively. If you want your users to drill from your **embedded dashboard A** to dashboard B, C, and D only, include id 123, 456, 789 inside `drillthroughs`:
```ruby
drillthroughs = {
"123": {
# other settings of dashboard 123 go here
},
"456": {
# other settings of dashboard 456 go here
},
"789": {
# other settings of dashboard 789 go here
}
}
```
You can get the dashboard ID from its URL:
### Override default filter values in your destination dashboard (optional)
If you want to overwrite the default filter values for your destination dashboard, add a `filters` value nested under the destination dashboard_id with the filter names you want to customize.
```ruby
drillthroughs = {
"123": {
filters: {
country: {
default_condition: {
operator: "is",
values: ['Vietnam'],
modifier: null
}
}
}
},
"456": {
# other settings of dashboard 456 go here
},
"789": {
# other settings of dashboard 789 go here
}
}
```
In the example above, we set the default country value to "Vietnam" in the destination dashboard by updating the **default_condition** values. Users can still change the filter to other values when interacting with the dashboard.
---
## Single-dashboard embedding
## Introduction
Single Dashboard embedding lets you **embed a single Holistics dashboard** into your web application via an iframe. Your users see the dashboard directly inside your app with no separate login required. You control what data each viewer can access through row-level permissions.
If you need to embed multiple dashboards or enable self-service analytics for your users, check out [Embed Portal](/embedded/embed-portal/).

## How it works
On the **frontend**, the dashboard is loaded in an **iframe**. The iframe URL contains two pieces of information: an `embed_code` that identifies the dashboard, and a time-sensitive `token` that controls what the viewer can see.
On the **backend**, your server generates the `token` using a secret key. The token is a JWT that encodes dashboard settings, filter defaults, and row-level permissions for the current user.
Here's the high-level flow:
1. **Create an embed link**: From your dashboard's settings, generate an `embed_code` (identifies the dashboard) and a `secret_key` (used to sign tokens).
2. **Generate a token on your backend**: Build a JWT payload with settings, permissions, and filters, then sign it with the secret key. The Embedded Analytics Sandbox helps you generate this code interactively.
3. **Embed the iframe in your frontend**: Construct the embed URL with `embed_code` and `token`, and add it to your page via an iframe.
:::tip Ready to implement?
Head to the **[Quickstart](/embedded/quickstart)** for a full walkthrough with code examples in JavaScript, Python, and Ruby.
:::
## Next Steps
- **[→ Quickstart](/embedded/quickstart)** - Embed your first dashboard in ~10 minutes with step-by-step code examples
- **[→ Basic Settings](/embedded/single-dashboard/basic-settings)** - Configure dashboard behavior, appearance, and user controls
- **[→ Permission Settings](/embedded/single-dashboard/permission-settings)** - Set up advanced row-level data access and security
- **[→ Drill-Through](/embedded/single-dashboard/drill-through-embed)** - Enable interactive data exploration and drill-down capabilities
---
**Need help?** Check our [FAQs](/embedded/faqs) or contact support for assistance with your implementation.
---
## Permission settings
## Introduction
**Permission Settings** is where you enforce data access control on your embedded dashboard viewers. The mechanism Holistics uses for this is **Row-level Permission (RLP)**.
### Row-level permission explained
For example, say you have an embedded dashboard for your multi-national E-commerce company which shows sales for all stores worldwide. Without RLP, no matter which manager signs in and views the report, they all see the same data.
Now the company CEO has decided that each country manager should only see the sales for the stores they manage. RLP lets you restrict data based on the area each manager is responsible for.
By applying RLP on the `name` field of the model `country`, whenever your country managers log in, the condition `country.name = 'their_country'` is applied to all queries generated by the embedded dashboard. This way, they can only see data linked to their country.
## General structure of permission settings code
You can generate permission settings code interactively within the Embedded Analytics Sandbox:
In general, the generated code will have this structure:
```js
permissions = {
"row_based": [
{
"path": {
"dataset": "dataset_name",
"model": "model_name",
"field": "field_name"
},
"operator": "expected_operator",
"modifier": null,
"values": [
"your_expected_value"
]
}
]
}
```
The **row_based** property is an array of **permission rules**. Each rule combines `path`, `operator`, `modifier`, and `values` to construct a condition that restricts data for a particular field.
- **path:** defines the **exact field** the condition applies to. Since row-level permission is applied at the dataset level, the path needs to include the **dataset unique name**, **data model name**, and **field name**.
- **operator:** specifies the comparison type (IS, IS NOT...) for the field.
- **modifier (optional):** only available for some of the operators in the Date filter (for example, next, last X days/months/years.)
- **values**: the only values accepted by the field comparison
:::tip
While you can write the embed code from scratch, we recommend using the **Embedded Analytics Sandbox** to retrieve the **exact field path** for your RLP settings.
:::
## How to restrict data access based on users
Below is sample code for permission settings that ensures:
- **General Manager** can see data from all countries.
- **Vietnam Manager** can only see data from `Vietnam`.
- **Consultant** can only see stores in `Ha Noi`.
```js
country = []
cities = []
if (current_user().email == "vn_manager@ecommerce.com") {
country = ['Vietnam']
} else if (current_user().email = "consultant@ecommerce.com") {
country = ['Vietnam']
cities = ['Ha Noi']
}
permissions = {
row_based: [
{
path: {
dataset: "ecommerce",
model: "ecommerce_countries",
field: "country_name"
},
operator: 'is',
values: country
},
{
path: {
dataset: "ecommerce",
model: "ecommerce_cities",
field: "city_name"
},
operator: 'is',
values: cities
},
]
}
```
## FAQs
### Why do permissions have different datasets from what we originally selected?
This happens because the dataset was renamed.
- The UI on the left displays the title of the dataset that has been renamed. The code on the right uses the dataset's unique name.
- The dataset's unique name is auto-generated when the dataset is created. We don't update it to avoid breaking existing embed payloads.
### What are the differences between permission settings and control settings?
Although it seems that both permission settings and [control settings](/embedded/single-dashboard/basic-settings#control-settings) can restrict user's access to data, in reality, they serve two different purposes.
Please read more about the distinction in the [Security](/embedded/security#enforcing-data-access-control-the-right-way) documentation page.
---
## White-label Ask AI in embedding
White-labeling Ask AI lets you replace Holistics' default branding with your own so the AI assistant feels like a native part of your product. You can set a custom icon, give the assistant a name, write your own welcome message, and tailor the prompt placeholder text.
Customizations are defined in AML, so they stay version-controlled and can differ per portal.
**Default:**
**Customized:**
## What you can customize
### Chat page
The chat page is where users interact with Ask AI. It appears on the AI page, in dashboards, and in data exploration. You can customize the icon, welcome message, and prompt placeholder.
Ask AI page
Dashboard
Data exploration
### Toolbar
The toolbar button is how users open Ask AI from wherever they are. It appears across dashboards, datasets, and widgets. You can customize the icon of the toolbar button.
### Left panel
The left panel is the sidebar entry point to Ask AI. You can customize the icon and name of the left panel.
## Syntax
Add an `ai` block to your `EmbedPortal` definition:
```aml title="sales_portal.embed.aml"
EmbedPortal sales_portal {
objects: [
sales_dashboard,
ecom_dataset,
]
ai {
customization: EmbedPortalAiCustomization {
global {
icon: 'https://cdn.example.com/logo.png'
assistant_name: 'Ask Hooli'
}
chat_page {
icon: 'https://cdn.example.com/chat-icon.png'
intro {
header: 'Hi, what would you like to know?'
body: 'Ask questions about your data in natural language.'
}
prompt_placeholder: ['Ask me anything, eg: Top 5 products by revenue', 'Try: Show monthly revenue trend', 'Try: Which region has the highest sales?']
}
toolbar {
icon: 'https://cdn.example.com/toolbar-icon.png'
}
left_panel {
icon: 'https://cdn.example.com/panel-icon.png'
}
}
}
}
```
Here's how each property maps to the interface:
## Properties reference
All properties are optional. Only set the ones you need:
| Section | Property | Type | Description |
|---------|----------|------|-------------|
| `global` | `icon` | String (URL) | Default AI assistant icon |
| | `assistant_name` | String | Custom name for the AI assistant |
| `chat_page` | `icon` | String (URL) | Override AI assistant icon for the chat page |
| | `intro.header` | String | Chat intro header text |
| | `intro.body` | String | Chat intro body text |
| | `prompt_placeholder` | String or String[] | Placeholder text in the prompt input. When multiple values are provided, they rotate every 3 seconds |
| `toolbar` | `icon` | String (URL) | Override AI assistant icon for the toolbar button |
| `left_panel` | `icon` | String (URL) | Override AI assistant icon for the sidebar |
### Icon resolution
You can set a custom icon in four places:
| Property | Where it appears |
|----------|-----------------|
| `global.icon` | Default icon for all UI locations |
| `chat_page.icon` | AI page, dashboard chat panel, Explore Data chat panel |
| `toolbar.icon` | AI button in dashboard, dataset, and widget toolbars |
| `left_panel.icon` | Sidebar navigation |
Each UI location resolves its icon with a fallback: per-section icon > `global.icon` > default Holistics AI icon. This lets you set one icon globally and override specific locations as needed.
For example, with this configuration:
```aml
global {
icon: 'https://cdn.example.com/logo.png'
}
chat_page {
icon: 'https://cdn.example.com/chat-icon.png'
}
```
- **Chat page** uses `chat-icon.png` (per-section override)
- **Toolbar** and **left panel** use `logo.png` (fallback to `global.icon`)
---
## Creating reports directly from SQL?
## Introduction
Holistics doesn't support creating reports directly from a SQL query, but instead asking you to define "models" and "datasets" before creating reports. If you have experience working with BI tools with a "SQL to charts" approach (e.g Redash, Chartio), you might find this approach unfamiliar.
This post talks more about why, and offers you a workaround if you still prefer SQL to charts experience.
## Why we follow modeling-based approach
In a "SQL to charts" BI tool, you simply write and run a SQL query, select some chart types and save it as a report. While relatively straightfoward, the downsides of this approach are:
- **Fixed Reporting:** Non-technical users cannot customize their own reports without knowing SQL.
- **SQL definitions sprawl:** As reports increase, reports definitions get duplicated all over the place.
Holistics takes a different approach by introducing a semantic/modeling layer in between. While it takes more setup time, the benefits of this approach are:
- **Self-service:** Non-technical users can build their own reports without relying on data teams.
- **Central definitions:** All business logic are centralized and organized in one place.
## Still, how can I create reports from a SQL query?
If you have some complex reporting logic that the available Datasets cannot satisfy, you can package your SQL transformation in a [Query Model](/docs/query-models.md), and create a reporting widget on top of that model by following these steps:
1. Go to **Data Modeling** page.
2. Click **+ Create → Add Data Model from Data Transform**
3. Write your SQL transformation and save it as a Query Model
4. Add relationships if needed
5. In the Data Model UI, click **Explore** and create a Dataset
6. From the Dataset explore, drag and drop to build your visualization
7. Turn that into a report
---
## ERROR: "failedPrecondition: this operation is not supported for this document"
When I import a Google Spreadsheet or export to Google Spreadsheet, I encounter this error:
`failedPrecondition: this operation is not supported for this document`?
This error happens normally because the file was saved in .xlsx extension and you need to converted it to Google Sheet format by importing the excel file in your google spreadsheet instead of directly uploading it to Google Drive.
---
## Does Holistics support Flow-based scheduling with Airflow?
In Holistics 3.0, we have **NOT** supported Flow-based scheduling yet but it is in our backlog and will be released soon.
In Holistics 2.0, we support [CLI](https://docs-v2.holistics.io/docs/holistics-cli/) so you can integrate Holistics with your current flow and manage the whole Flow-based schedule on your side (not Holistics).
For example, you can use Holistics with airflow to manage Flow-based schedule like the pseudo-code below:
```
# File users.flow
command: '$ holistics import:run -n users'
# File users_daily.flow
depends: 'users'
command: '$ holistics transform:run -n users_daily'
```
---
## Holistics 2.7 FAQs
### What this is about
Starting 05 Jan 2021, Holistics will be opening up access of our data modelling to all
our existing customers (on version 2.0 and version 2.5). Most of these customers would have subscribed to Holistics before Feb 2020.
Holistics customers who sign up after Feb 2020 would have been using Holistics 3.0.
### What's wrong with my current version of Holistics?
Holistics started off as a software that helps our customers convert SQL to charts. This
simplicity is great for SQL analysts looking for a quick and easy way to extract, visualize, and share data. However as time passes, the following issues occur:
1. Because you need to know SQL to build your own reports, non-technical users finds it hard to build their own reports and need to ask data teams for help for minor revisions in the choice of data fields on a report
2. Because reports are written in an ad-hoc nature, it ended up that the same metrics in different reports may contradict each other.
3. Because SQL queries are not reusable and composable, data teams spend administrative time rewriting the same queries repeatedly for multiple reports. We also often get requests from analysts who wants to find the list of reports who query specific database table(s).
That version of Holistics (SQL -> Charts) which a number of our customers are using is what we call Holistics 2.0.
Another issue we are solving is the merging of the roadmaps for all our customers. Otherwise, the roadmap plans for Holistics 2.0 will get increasingly limited.
### How is Holistics 3.0 different from 2.0?
While Holistics 2.0 reports are built using reports from disparate SQL queries (each report contains their own SQL query), Holistics 3.0 reports are built from datasets. And datasets are built from data models. See below diagram
Some additional benefits that Holistics 3.0 has over 2.0 as below:
1. Datasets allows non-technical users to build their own charts and reports from scratch without the need to know SQL
2. As datasets are virtual (no additional physical storage) and their definitions are maintained centrally through data models, companies can build any number of datasets without the ongoing maintenance (disparate definitions) and infrastructure (physical storage) overheads.
### If Holistics is already on Holistics 3.0, why am I being moved to Holistics 2.7?
So that you need not recreate the reports that you have created previously, or reinvite
your users (set permissions from scratch).
Holistics 2.7 merges the capabilities between the legacy version (2.0 and 2.5) and 3.0 in that it supports the coexistence of reports from both
1. Reports and filters built off parameterized SQL queries (Holistics 2.0 and 2.5)
2. Reports and filters built off SQL-based data models (Holistics 3.0)
### What happens to my existing reports and definitions after the move to Holistics 2.7?
Your existing reports will remain with you. You don't need to recreate them. That was why we created a specific version for our existing customers.
### But I still prefer to write reports with adhoc SQL queries. Can I still keep it that way?
Yes. Holistics 2.7 will allow you to create reports from both stand-alone SQL queries (current approach), or reports from data models (new approach).
### What do I need to watch out for during this upgrade from Holistics 2.0 to Holistics 2.7?
The most important setup is organization of your existing 2.0 reports as Holistics 2.0 (standalone SQL) reports and dashboards are not compatible with the new (Holistics 3.0) modelling/datasets-based reports.
What we recommend for a start is as below:
- Create a dashboard folder to hold all your existing (2.0) reports, the ones that's built through standalone SQL reports.
- Create a dashboard folder to build new reports that's built with data models
### Besides the report creation approach, what other known issues/gaps I need to be mindful of when building reports thorough data models?
There are some features for Holistics 2.0 reports that are not yet available in Holistics 3.0, and we are porting them over to 3.0 gradually. The list of differences can be found in our [Product Versions Comparison](/docs/product-versions) doc.
### Do we really have to move to 2.7 or 3.0? Can we just remain in our current version?
The move to Holistics 2.7 will not cause you to lose any functionality. If self-service reporting and data exploration is not your core use-case, you can just remain using Holistics 2.7 the same way you did for Holistics 2.0.
### Is Holistics 2.7 really superior to Holistics 3.0, ie no downsides relative to 3.0?
Yes and no. Yes in that Holistics 2.7 gives you the flexibility of SQL queries, while
also giving you the option to setup reusable data components for self-service
analytics (3.0).
The downside is that there's 2 separate learning curve (building the reports with both versions). That may be easy for existing data teams (since you only need to learn the new approach), but new-joiners (especially for the data teams) will need more guidance. Our team is always happy to help there!
### I don't think I want to use Holistics 2.7. Can you move me to Holistics 3.0 directly?
Technically you can do so, and we considered that option for our customers too. However,
doing so will require you to do the following:
1. Sign up for a new Holistics account (inform us first).
2. Rename the email addresses of your existing users in the old Holistics account to a different name, say "name+holistics2@company.com". Our current system don't allow duplicate email accounts across different tenants.
3. Recreate your existing reports and re-invite your users in the new version.
As you can imagine, that's a fair bit of work on your side. We have tried this with some customers and the migration process for them internally is quite difficult unless they make the migration a priority.
Existing customers who are suitable to move over to 3.0 are either those who currently have little reports/dashboards in Holistics.
Holistics 2.7 will reduce the effort you need to make significantly for the migration.
### Can I get 2 sets of accounts instead for my existing Holistics version and the new Holistics?
We thought about that, but the billing and admin might be more complicated. We can provide some time (up to a month) for a concurrent run of both accounts for you to do the migration if you are really committed to make the move to Holistics 3.0. We hope to get your understanding on this.
### How will my pricing be impacted?
Holistics 3.0 is priced based on the number of objects, and the [pricing details are published on our
website](https://www.holistics.io/pricing/) now. This should be familiar for most of you, except that the objects count and definitions may vary depending on the time you signed up for Holistics. We find the new pricing model better refined to address specific concerns.
For our customers on users + addon based pricing plan (ie legacy). The long term plan is to move over to the objects-based pricing plan (with some exceptions if it does not make sense) .
Feel free to contact us to let us know and we will be happy to provide you with a price-impact analysis and work out what works best for you. We believe for most customers, it will be better for them to go on our new pricing plan.
### Ok, so what's next? How do I get onboard Holistics 2.7?
You don't need to do anything. Starting 5th Jan 2021, all Holistics 2.7 will be enabled
for all our customers.
### Can you open up Holistics 2.7 to me earlier (before Jan 2021)?
Yes we will be happy to provide you early access.
### Can I get a demo to understand how Holistics 3.0 work?
Yes you can contact Janice or any of our team members to setup an intro call. We are also gathering interest on either a webinar (or recorded video) soon to share the key differences between Holistics 2.0 and 3.0.
### How is Holistics 3.0 different from [name of BI vendor] ?
Happy to share more details with you over a call, but in general - Holistics focus a lot
on analytics workflows to make self-service analytics successful. Getting
charts is easy, but answering data requests and organizing data components take
a lot of effort. Feel free to let us know if you like to know more information!
### I have more questions. Who can I contact?
Feel free to email Janice Lee (most of you should know her email) for your questions, or
email our support team (if you don't have Janice's contacts). Janice will help
to link you up with the right person in our team to answer your questions.
---
## Holistics FAQs
## Does Holistics store my data?
Please refer to [this documentation](/docs/security-compliance/data-security#does-holistics-store-my-data) for more detail.
## Is Holistics a cloud-based or on-premise solution?
Holistics is a fully cloud-based solution that connects with your SQL database.
There is currently no on-premise version of Holistics. If on-premise deployment is necessary for your BI solution, please indicate your interest [here](https://form.jotform.com/222271659101045) and we will reach out if there are any developments on this front or if we have any questions for you.
## I work for a big enterprise. How difficult is it for me to plan and budget for what I need?
We understand as we have worked with enterprises before. Contact us and we will be happy to work out a simplified pricing plan (Enterprise) based on your needs.
## Does Holistics offer white label analytics and OEM embedded dashboards?
Yes. You can [contact us here](https://www.holistics.io/contact-us/), to discuss how that can be done.
---
## Data Modeling Troubleshoot
## Field not found/not exist/not recognized
In general, it means that the field you does not exist in the table/CTE you are referring to. This error can happen in the following cases:
### Normal SQL syntax is mixed with Holistics's syntax.
In a query, when you used `{{ #alias.field_name }}` syntax to refer to some fields, only those fields are selected in the source CTE and can be referred with `alias.field_name`. If you happen to use `alias.field_name` to refer to a field outside of the available, a "column not found/not exist" error will be raised.
In the example below, when querying model `ecommerce_orders` and refer to ID field with `{{#o.id}}`, only the ID field is available in the base CTE, and field `created_at` is not available.
### Custom fields/measures in the upstream CTE are not named
Calculated fields and measures are actually SQL statements that will be inserted into the final query. You need to name the column resulted from the statement, or the column name will revert to the database's default.
In the example above, because we did not name the calculated field `age_group`, the resulted column name falls back to PostgreSQL's default and of course the name `age_group` does not correspond to any column.
### A field is missing in an upstream model
If you have two models like this:
```sql
{
model_name: 'model_1'
query: '''select 1 as field_1, 2 as field_2'''
}
-- Not using Holistics's syntax
{
model_name: 'model_2'
query: '''select m.field_1, m.field_2 from {{#model_1 m}}'''
}
-- Using Holistics's syntax
{
model_name: 'model_22'
query: ''' select {{#m.field_1}}, {{#m.field_2}} from {{#model_1 m}}'''
}
```
`model_2` depends on `model_1` and refers to both `field_1` and `field_2`. If for some reason one of the referred field in `model_1` is removed:
```
{
model_name: 'model_1'
query: '''
select
1 as field_1
-- 2 as field_2 -- Removed field_2
'''
}
```
All subsequent models referring to the removed field will be broken:
If the downstream model uses Holistics's syntax, you will receive a more descriptive error message that makes it easier to trace the bug:
## Error line number does not match
When you use Holistics's query syntax, what you write will be parsed into a full query in your database's SQL flavor. This means that the **actual query run against your database** will be **longer than what you write** in the SQL editor, especially when your custom fields and measures are complicated:
In this example, the error happens at line 16 in the executed query but the cause of it lies in line 4 in the query editor, where we missed a comma.
Therefore, when debugging the query, it is best to check the Executed Query panel for the final SQL error, then trace it back to what you write in the query editor.
## Illegal model/field naming
When naming your models and fields, it is best to avoid SQL/database keywords like `user`, `order`, `limit`, `select`... as it may raise **Unexpected keyword** error.
---
## Data Reporting Troubleshoot
## Result data is different from your expectation
There can be some cases where you find the result data different from what you expected:
* The result has less or more data than you expected
* The result data seems wrong to you
In these cases, we would recommend going through the following steps to troubleshoot:
### 1. Check the cache status right on the right side of the Dashboard title.
The result might have been cached and you are seeing an old result. Read more about Holistics Caching Mechanism [here](/docs/performance/data-caching).
### 2. Check if the Executed Query matches your expectation.
You can find the Executed Query in the bottom section of an expanded widget, or in your Job Logs.
If the Executed Query does not match your expectation, you should
1. Review the Filter settings on the Dashboards (if any) and check whether they have been correctly mapped to your Report. Read more [here](/docs/filters#setting-up-filter-controls).
2. Review the Visualization settings in the Report, check whether you have the correct fields selected.
3. Review the Data Models and Relationships definitions/configs in the Dataset of your Report. Common problems:
* The Link Type of the Relationships (many-to-one/one-to-many/one-to-one) was incorrectly set. Read more about Relationships [here](/docs/relationships).
* The Relationship setup in your Dataset has wrong join paths. Read more about Relationships in Dataset [here](/docs/datasets/dataset-relationships).
### 3. Check the data in your Data Warehouse
For example, you check try running the Executed Query directly on the database console.
Also, it is possible that the data loading process (either via Holistics or other tools) has some problems. Because of them, wrong data was loaded, or the data loading has not even been completed yet, etc.
### 4. Contact Holistics Support
If you have gone through all the steps but are still not sure why the result data is different, please contact support@holistics.io for further assistance.
In order for us to troubleshoot better, please also let us know:
* The relevant findings from the steps above
* Your expected result, and how/where you got that result
---
## What view access options or sharing options are available in Holistics?
There are 4 ways you can grant read access to your reports with Holistics, each with its own applications and pricing methods. You can consider what mix of methods would work best for your team.
## 1. Viewer Users
- You can invite members of the organization into Holistics as viewers or explorers.
- Viewers and Explorers are paid user positions and have view access to the reports and dashboards that are specifically shared with them without any editing access,
- Explorers can explore the data that is shared with them and create personal private dashboards.
- ✍️ Please refer to [this doc](/docs/admin/user-roles#role-permissions-matrix) for a list of the different permissions across user types.
- Additional benefits to this approach are that you can use [user attributes and groups](/docs/admin/user-attributes) as well as [row-level permissions](/docs/access-control/row-level-permission) to automatically apply permissions and share dashboards at scale.
## 2. [Shareable Links](/docs/delivery/shareable-links)
- Shareable Links allow you to share dashboard access to external users with strict access control measures in place so that they only see the data specifically prepared for them.
- You don't need a Holistics account to access the dashboard.
- With Shareable Links, you can restrict the data (by particular conditions) for each link shared and set password protection for each link.
## 3. [Export options](/docs/delivery/export-data#export-to-email--slack--google-sheets--sftp--telegram)
- You can schedule dashboards to be exported outside of the Holistics platform via email (with a range of attachment file types), Slack, and Google Sheets.
- These will be snapshots at the scheduled time of the report using the freshest data available.
## 4. [Embedded Dashboards](/embedded)
- If you have a web application that your users can log into, you can consider embedding Holistics reports into it. It will allow your members to view dashboards and have permissions automatically applied to them based on how your engineers define their viewing rights. They will also not need paid users to log into Holistics.
- Embedded dashboards do not cost additional objects. However, you will have to enable [embed workers](/docs/jobs/queues-and-workers) to allow embedded dashboards to function. These workers will handle all the operations required by your embedded dashboards and are shared across all active embedded dashboards.
- You must have a minimum of 3 workers in order to use embedded dashboards. You can increase the number of embed workers as the usage scaled and you find that embed dashboard job queues are being clogged with too many simultaneous logins.
For pricing, please refer to your in-app billing for more information.
---
## Adding annotations to charts
## Introduction
Annotations are useful when you want to add contextual information to your charts, such as marking important events, changes, or milestones that may have impacted your metrics.
For example, you might want to annotate when you launched a new product feature, ran a marketing campaign, or encountered system issues - this helps viewers better understand the trends and patterns in your data.
By following this guide, you will learn how to create annotations that can be included in your dataset and reused in any charts that you build from that dataset.


## General Approach
To add annotations to your charts in Holistics, you'll need to follow these three main steps:
1. Create a data model containing your annotations data with event dates and descriptions
2. Join this annotations data with your main dataset using a date-based relationship
3. Configure your chart visualization to display the annotations at the specified dates. The annotations will appear as a separate line on the Y-axis of your chart.
## Implementation
### 1. Create the Annotations Data Model
First, you'll need to create a [Table Model](/docs/table-models) or use SQL to create a [Query Model](/docs/query-models) on the fly that returns two essential columns:
- `event_date`: The date of the annotation
- `annotation`: The text description of the event
Here's a sample query to create an on-the-fly data model:
```sql
Model annotations {
type: 'query'
label: 'Annotations'
description: 'Model for storing chart annotations and important events'
data_source_name: 'demo_pg'
dimension event_date {
label: 'Event Date'
type: 'date'
hidden: false
definition: @sql {{ #SOURCE.event_date }};;
}
dimension annotation {
label: 'Annotation'
type: 'text'
hidden: false
definition: @sql {{ #SOURCE.annotation }};;
}
query: @sql
SELECT
CAST('2024-01-15' AS DATE) AS event_date, '🦶 Project kickoff meeting' AS annotation
UNION ALL
SELECT
'2024-03-22', '🚀 Product launch event'
UNION ALL
SELECT
'2024-06-10', '🕛 Mid-year review'
UNION ALL
SELECT
'2024-09-05', '🎫 Annual conference'
UNION ALL
SELECT
'2024-12-31', '🗓️ End of year summary'
;;;
models: []
}
```
The resulting data table will look like this:

### 2. Add the Model to Your Dataset
After creating the annotations model, you need to integrate it with your existing [dataset](/docs/datasets), and set up a relationship between your main date dimension and the annotations date.
For example, in this case, we create a "one-to-one" relationship between `date_dim.date` and `annotations.event_date` in the dataset `ecommerce`.

### 3. Display Annotations in Your Chart
Next, we will set up annotations to appear as markers on your chart, with each point representing a specific event.
The annotations will be displayed on a secondary Y-axis. Each annotation point will have a tooltip that displays the content of the annotation when you hover over it.
With this approach, you have to use either **combination chart** or **line chart** or **column chart** to enable secondary Y-axis.
#### Setup A - Annotations as Data Points on Line Chart
**A.1 Configure Chart Data Settings**
In addition to your normal chart setup:
- Create a measure `count(annotations.annotation)`, then place it on the secondary Y-axis, and set its visualization type to line chart. This will display a data point on any date that has at least one annotation.
- Create another measure `max(annotations.annotation)` and add it to the tooltip section.

**A.2 Adjust Chart Styles**
For the secondary Y-axis,
- Set appropriate minimum and maximum values to position annotation points at the bottom of the chart
- Enable "Show data points" option
- Disable "Connect discontinuous points" option to prevent unwanted line connections

#### Setup B - Annotations as Vertical Lines

**B.1 Configure Chart Data Settings**
In addition to your normal chart setup:
- Create a measure `count(annotations.annotation)`, then place it on the secondary Y-axis, and set its visualization type to column chart. This will display a data column on any date that has at least one annotation.
- Make the color of the secondary Y-axis lighter than the main Y-axis.
- Create another measure `max(annotations.annotation)` and add it to the tooltip section.

**B.2 Adjust Chart Styles**
For the secondary Y-axis, set appropriate minimum and maximum values to position annotation points at the bottom of the chart

---
## Calculate Percent of Total using SQL
:::info Native Support
Holistics now has native support for [Percent of Total](/as-code/aql/cookbook/aql-percent-of-total) calculation. Use it for a simpler and faster experience.
:::
Percent of Total enables you to find the percent distribution of a measure, broken down by one or many dimensions.
In this guide, I will walk you through the detailed steps on how to find the percentage of a value in the total with Holistics modeling.
## Context
Let's say we have a table/model called `percentage_of_total_raw` that contains 6 fields:
```dbml
Table orders {
order_id integer
order_created_date datetime
category varchar
parent_category varchar
country_name varchar
quantity integer
}
```
Let us assume we want to build 2 reports that can:
- Find out the sales percentage of each `category` with respect to the total sales (based on `quantity`).
- Find out the percent of total sales per category and country for each individual day. This will affect the denominator of the percentage that the total sales are grouped by each day.
- Filter the report by any dimension (category, date...) and when using a filter, the percentage of total does not reflect the percent of filtered records to the total records in the data.
This is how the 1st report would finally appear:
## General Solution
$$
\large percentage = \frac{Partial\space Amount}{Total\space Amount}
$$
We perform some SQL transformations to enable granular calculations of the percentage of total with the following steps:
- Create Model 1 or Subquery 1: Calculate the numerator of the percentage in which the total value is grouped by one or many dimensions.
- Create Model 2 or Subquery 2: Determine the whole or total amount of what you want to find a percentage for in the denominator of the percentage.
- Link these models or join these subqueries and add them to the exploration.
- And finally, you need to divide the sub-amount by the total using a business calculation to get the percentage.
## Use case 1: The simple percent of total sales per category
In this case, we would like to divide the total sum by only **one attribute** such as `category`.
Particularly, we need to take the Sum of sale quantities for each category and divide it by the Total sale quantities for all categories.
$\large percentage = { 𝐒𝐮𝐦\space(quantity)\space \space 𝐠𝐫𝐨𝐮𝐩\space 𝐛𝐲\space(category,country,date) \over 𝐒𝐮𝐦\space(quantity)\space \space 𝐠𝐫𝐨𝐮𝐩\space 𝐛𝐲\space(date)}$
### Using query model
1. First, we build the `percent_of_total_per_category (category, total_by_cate, total)` model where `total_by_cate` measures how many quantities are in each category, and `total` is the total sales of all categories in the denominator.
We do that by creating two subqueries to calculate the numerator `total_by_cate` and the denominator `total` and then, join them **ON TRUE**
```sql
with pt_all as (
select
sum(quantity) as total
from
{{ #percentage_of_total_raw t1}}
) -- to calculate denominator (total)
,
pt_sale_by_cate as (
select
category,
sum(quantity) as total_by_cate
from
{{ #percentage_of_total_raw }}
group by 1
) -- to calculate numerator (total_by_cate)
select
pt_sale_by_cate.category,
pt_sale_by_cate.total_by_cate ,
pt_all.total
from pt_all join pt_sale_by_cate on true
```
2. Put the data model in a data set and calculate percentages with business calculation
To get the **percent of total per category**, we will create a new measure called **% per category** which divides **`total_by_cate`** by **`total`**
```sql
sum(total_by_cate) / max(total)
```
### Building the chart
#### Using Pivot Table
Drag your `category` to "Rows" and `percentage_of_total_raw.quantity` to "Values" with the setting to Sum.
Add the calculation `% per category` to "Values".
Go to "Styles" to enable your Column Totals and Sub Total. Now you have all your percent of total per category as the output we showed in [Context](#context) part.
#### Using Pie/Donut Chart
To display a set of categories’ proportions or percentages of the total per **one attribute**, you can just simply use a pie chart or donut chart. There is no need to use the query model to first work out the percentage of the pie chart that each category should occupy. You can just let the visualization do the percent of total calculation by itself.
The percentages will be listed in the legend, alongside the records to which they belong. Under "Styles", enable "Show percentage" to display the percentage that the sectors represent instead of the raw value.
> ***Note:***
A pie chart is often used to compare each group’s contribution to the whole, as opposed to comparing groups to each other.
>
## Use case 2: The percent of total per category and country for each day
The result will show:
- The order's country and category
- Total orders (sale quantities) of each category for each certain day
- Percentage of that category over total sales of all categories for each certain day
$\large percentage = { 𝐒𝐮𝐦\space(quantity)\space \space 𝐠𝐫𝐨𝐮𝐩\space 𝐛𝐲\space(category) \over 𝐒𝐮𝐦\space(quantity)}$
### Overall data transformation
Using the base model `percentage_of_total_raw`, you can simply measure the numerator using **aggregate function `SUM`** in the exploration UI.
You will need an `orders_total_not_broke_down_by_category` model to calculate the total sales of all categories on each day in the denominator.
In order to get the single entry point for 2 models, you need a date dimension model (`date_dim` model) and join it with `percentage_of_total_raw` and `orders_total_not_broke_down_by_category` using a created common `date` field in all 3 models
> ***Note:***
In this case, the denominator number is the total sum grouped by each date. That's why we chose `date_dim` model as a junction model. In other quarters, we use `date_dim` model to compare metrics from different models by date.
>
### Create Orders Total Not Broken Down By Category model
```sql
select
{{ #t1.order_created_date }},
{{ #t1.country_name }},
-- **We remove the category column here** {{ #t1.category }},
sum({{ #t1.quantity }}) as quantity
from {{ #percentage_of_total_raw t1}}
group by 1,2
```
### Joining the models
To add the `orders_total_not_broke_down_by_category` model to the same exploration with `percentage_of_total_raw`, we have to link them somehow.
An experienced modeler might notice that we can create a ‘1 - n’ relationship from `orders_total_not_broke_down_by_category` to `percentage_of_total_raw` by creating a `join_key` to both the original model and the total model like `country_name || '-' || CAST(date as text)`. The problem with that approach is that only the total from countries that exist in a category would show up.
The recommended way to deal with this is to create a [Date Dimension model](/guides/using-date-dim-model).
### Create Date Dims model
`date_dim` - a created common model to be the base of JOIN operations
```sql
select
generate_series( min({{ #a.order_created_date }}), max({{ #a.order_created_date }}),'1d')::date as dates
from
{{ #percentage_of_total_raw a}}
```
Date in `date_dim` model is unique so we can create two ‘1 - n’ relationships to `percentage_of_total_raw` and `orders_total_not_broke_down_by_category` .
Now `orders_total_not_broke_down_by_category` returns the total for the day regardless of category.
### Create the dataset and calculate the percentage
Now, create the dataset, add the `orders_total_not_broke_down_by_category`, `percentage_of_total_raw` and `date_dim` models to the dataset and it will be ready to be explored.
Calculate the percentage `% for each day` with business calculation as below:
```tsx
sum(percentage_of_total_raw.quantity) / sum(orders_total_not_broke_down_by_category.quantity)
```
## Building the chart
When you only need the percent of the total in the visualization, you can use one of the following chart types to do the percent of total calculation:
- Column
- Bar
- Scatter chart
- Line
- Area
- Pie chart and Donut chart
When you need both the partial amount and the percent of the total in the visualization, you can use:
- Table and Pivot Table
- Combination Chart.
### Displaying using a Column chart
Here is an example to show how the chart looks and acts using the Column chart with "**Stack Series**" option.
### Displaying using Pivot Tables
Drag your `category` to "Rows", **Day** `order_created_date` to "Columns" and `percentage_of_total_raw.quantity` , `orders_total_not_broke_down_by_category.quantity` to "Values" set to Sum.
Add the calculation `% for each day` to "Values".
Go to "Styles" to enable your Column Totals and Sub Total. Now you have all your percent of total per category for each day.
### Dashboard filters
Right now, the filter can only be mapped to one field in one data model of the dataset so you'll have to create two filters, one to filter the numerator, and one to filter the denominator.
## Other Notes
### Percentage options
Computing a percentage involves specifying a total on which the percentage is based. With this query model, you can choose many different options: based on the entire table, a column, and a row.
For example, in use case 2, each measure on the table is expressed as a percentage of the total for the column. The values within the "percentage" column add up to 100%. Based on the ways that you build the query models and link them, you can also set each measure on the worksheet so that it is expressed as a percentage of the total for the row or for the entire table.
### Preserve “Percent of Total” when using the dashboard filter
If you want to filter out one or more of your dimensions, the ‘percent of total figure’ changes because the ‘total’ which is used in the denominator computation changes too to reflect the loss of the dimension members.
In some cases, you will want to keep the original percentage (of the whole underlying data) while just displaying the dimension members you are interested in.
For example, we have a table for Sales per category.
When we set a dashboard filter to only show Category = Category1, then the percent will be changed to 100%, as all other categories were excluded from the total.
Suppose that you want to keep the original percent of Category1 (25%).
What you can do is set up a filter that only filters the numerator. So the % of the total calculation will not change when you adjust the filter. This is due to the ‘total’ computed in the denominator not being changed.
---
## Build Cohort Retention Chart
## Introduction
Cohort retention report is a type of report that allows you to track how different groups of users engage with your platform over time. This tutorial will show you how to build a cohort retention report in Holistics.
Let's start with some definition:
- **A cohort** is a group of users who share common characteristics *around a time period*. For example: Cohort of students who enrolled in 2021, cohort of users who signed up on August 2021.
- **Retention**: A measure of how well your platform *retains* users. For example if 100 people sign ups for your restaurant on first month, but only 20 of them come back the next month, your "retention rate" is 20%.
- By looking at the *retention* over time of different *cohorts* of users (hence "**cohort retention**"), we can see if we are improving our products/services in the right direction.
By this post, we'll show you how to build the below Cohort Retention report:
> *By monthly cohorts, how many of our first-time buyers are coming back to make purchases the subsequent months?*
>
The above chart tells you a few things:
- **Cohort Month:** We **cohort** users by the **month** when they made their first purchase
- **Cohort Size**: How many users in that cohort; i.e how many users made their first purchase on that month.
- **Month 00, Month 01, Month 02 etc:** Months since the user has made the first purchase
- *For example: 436 users made their first purchase in Sep 2016. 87% of them came back (made at least 1 purchase) in Month 1; 75% of them came back in month 3, and so on.*
## Input Data
For this report, we only need a simple table `orders` that contains these fields:
- `id`: order id
- `user_id`: ID of the user
- `created_at`: when the order is made
## High-level Approach
There are 2 main steps involved in building a **cohort retention report** using Holistics:
1. From the raw input data, transform them into the right data format
2. Use Holistics' "Cohort Retention" chart type to visualize
The transformed data should look like the table below:
- The `cohort month` and corresponding total users in that cohort (`cohort size`)
- The months since the user has made the first purchase `month number` and how many users are still active on all subsequent months after their first purchase `number users`.
## Step-by-step Instructions
### High-level Transformation Diagram
As it's not a simple transformation, we break them down into multiple steps with interim charts. The diagram below puts the steps together:
### Step 1: Defining Cohort
We want to group our cohorts based on the month in which they made their first purchase and store them into `cohort_dfn (user_id, cohort_month)` model.
That means: For each user with `user_id`, which monthly cohort `cohort_month` does s/he belong to.
```sql
select
{{#o.user_id}},
date_trunc('month', min({{#o.created_at}}))::date as cohort_month
from {{ #orders as o}}
group by 1
```
```sql
// Cohort defined by first order => Output: user_id, cohort_month
Model cohort_dfn {
type: 'query'
dimension user_id {
type: 'number'
}
dimension cohort_month {
type: 'date'
}
query: @sql
select
{{#o.user_id}},
date_trunc('month', min({{#o.created_at}}))::date as cohort_month
from {{#orders as o}}
group by 1
;;
}
```
### Step 2: Calculating Cohort Size for each cohort
We build `cohort_size (cohort_month, total_users)` model which is simply how many users are in each cohort:
```sql
select
{{#c.cohort_month}},
count(distinct({{ #c.user_id }})) as total_users
from {{#cohort_dfn as c}}
group by 1
```
```sql
// Cohort size defined by number of users in the cohort
// Ouput: cohort_month, total_users
Model cohort_size {
type: 'query'
dimension cohort_month {
type: 'date'
}
dimension total_users {
type: 'number'
}
query: @sql
select {{#c.cohort_month}}
, count(distinct({{ #c.user_id }})) as total_users
from {{#cohort_dfn as c}}
group by 1
;;
}
```
### Step 3: Calculate if user X makes purchases in month Y
We build `retention_by_user_by_month (user_id, month_number)` model that indicates if user X has made a purchase in month Y.
Note that `month_number` is a integer value, denoting number of months since user's cohort month.
For example:
- User X belongs to cohort Sep 2019
- X makes a repeat purchase on Nov 2019
- Thus, there will be a record with `(X, 2)` (2 = months between November and September)
A sample table would look like:
```
| user | month_number |
| Alex | 0 |
| Alex | 1 |
| Bob | 0 |
| Bob | 2 |
```
The query:
```sql
select
{{#o.user_id}},
((date_part('year', {{#o.created_at}}::date) - date_part('year', {{#c.cohort_month}}::date)) * 12 +
(date_part('month', {{#o.created_at}}::date) - date_part('month', {{#c.cohort_month}}::date))) as month_number
from {{#orders as o}}
left join {{#cohort_dfn as c}} on {{#o.user_id}} = {{#c.user_id}}
```
```sql
// Months between the user’s acquisition date and their order date
// Output: user_id, month_number
Model retention_by_user_by_month {
type: 'query'
dimension user_id {
type: 'number'
}
dimension month_number {
type: 'number'
}
query: @sql
select
{{#o.user_id}},
((date_part('year', {{#o.created_at}}::date) - date_part('year', {{#c.cohort_month}}::date)) * 12 +
(date_part('month', {{#o.created_at}}::date) - date_part('month', {{#c.cohort_month}}::date))) as month_number
from {{#orders as o}}
left join {{#cohort_dfn as c}} on {{#o.user_id}} = {{#c.user_id}}
;;
}
```
### Step 4: Putting them together
From the data in step 3, we aggregate them and build `cohort_retention (cohort_month, month_number, num_users)` model. This indicates how many users in cohort X make purchases in month number Y.
Query below. We use `count distinct` to calculate number of users in each group `(cohort_month, month_number)` and assign it to dimension `num_users`.
```sql
select {{#c.cohort_month}}
, concat('Month ', to_char({{#r.month_number}}, 'fm00')) as month_number
, count(distinct({{#r.user_id}})) as num_users
from {{#retention_by_user_by_month as r}}
left join {{#cohort_dfn as c}} on {{#r.user_id}} = {{#c.user_id}}
group by 1,2
```
```sql
// cohort_month, month_number, num_users
Model cohort_retention {
type: 'query'
dimension cohort_month {
type: 'date'
}
dimension month_number {
type: 'text'
definition: @sql concat('Month ', to_char({{ #SOURCE.month_number}}, 'fm00'));;
}
dimension num_users {
type: 'number'
}
query: @sql
select {{#c.cohort_month}}
, {{#r.month_number}}
, count(distinct({{#r.user_id}})) as num_users
from {{#retention_by_user_by_month as r}}
left join {{#cohort_dfn as c}} on {{#r.user_id}} = {{#c.user_id}}
group by 1,2
;;
}
```
### Step 5: Create Dataset
Finally, we create a dataset `cohort` which contains 2 models `cohort_size` and `cohort_retention` with 1-n relationship. This is necessary to create the visualization.
Create the relationship between 2 models `cohort_size` and `cohort_retention`; then, add them to the new dataset called `cohort`.
Business users can now drag and drop to explore the data.
```sql
cohort_retention
}
cohort_size
}
Dataset cohort {
models: [
cohort_retention,
cohort_size,
]
relationships: [
rel(rel_expr: cohort_retention.cohort_month > cohort_size.cohort_month, active: true)
]
}
```
### Final Step: Building the chart
Now that we’ve built the dataset, go to Reporting and choose the Visualization Retention Heatmap, drag the fields for each element of chart.
It’s great to know our absolute user count by month, but what would actually be even better is to know what percentage of each cohort is being retained over time. We can achieve this via a simple click using **Styles tab** and **Toggle** on **Support Percentage Display**.
## Conclusion
Cohort retention analysis is a simple, yet effective way to understand the performance of your marketing retention and acquisition efforts.
---
## Consolidate data from different sources
To answer certain data questions for your organization, sometimes you need to pull data from different sources (CSV, Google Analytics, Google Spreadsheet or production data) to do analytics
At Holistics, [Import Models](/docs/import-models.md) are models created to load data from other sources (e.g. CSV, Google Analytics, etc.) to your current Data Warehouse. In this tutorial, we will walk you through simple steps to create Import Model and consolidate your Data
VIDEO
## Import Data
To import data into your data warehouse, please follow these three steps:
### 1. Choose which Data Warehouse you want to import data into
At this step, the pre-requisite is that you have already connected to your Data Warehouse. Then, go to *Data Modeling tab* and select the Data Warehouse as the destination you will import other data into.
### 2. Create a new Model from Data Import
In your Data Warehouse, just click **Create** → Select **Data Import**, then select a source from the list we are supporting.
As shown in the list, you can import data from your SQL database (PostgreSQL, BigQuery...), no-SQL database (MongoDB...), applications (Pipedrive, Google Analytics, Facebook Ads, Google Spreadsheet...) or a File (CSV).
If you cannot find your sources on our list, please contact us at [support@holistics.io](mailto:support@holistics.io).
### 3. Source Setup
Please note that the setup process might be slightly different between SQL, non-SQL databases and applications.
For example, if you want to load data from **Google Spreadsheet**, simply paste the Spreadsheet link in, click **Validate URL** and choose a table/sheet you want to load into your Data Warehouse.
You can also preview the structure of the destination table before proceeding.
At this step, you can edit the destination table's schema and name, as well as set **Refresh Schedule** and **Import Mode.**
Please note that by default, all the columns in your Google Spreadsheet will be cast into TEXT/STRING type when loading to your databases. You can change the column type in **Sync Configuration**.
That is just an example of **Google Spreadsheet** integration, the mechanism of other sources like **CSV, MongoDB** or **Pipedrive** will be relatively similar.
For **CSV files**, just simply upload the file, view table structure and make proper adjustment to the Destination Settings and Sync Configuration.
Among them all, **Google Analytics** seems to have the most different initial setup step. Following Google Analytics standard and its in-depth guide, you need to define the metrics, dimensions to construct [Google Analytics queries](https://support.google.com/analytics/answer/1033861). After setting up these dimensions and metrics, the rest will be similar to other sources (view data table preview, adjust table destination and sync configuration)
## Consolidate your sources
After successfully importing your external sources to your Data Warehouse, you can now combine them with other data models (SQL Model or Data Table) to enrich your organization's data.
For example, you have an ecommerce dataset that contains 5 models: users, orders, order_items, cities, and countries, but they are not in the same source (**users, orders,** and **order_items** are retrieved from Production Database, **cities** is recorded in a Spreadsheet life daily and **countries** comes from a CSV file).
Another use case is that you can aggregate orders count to the daily level, and combine it with an Import Model that pulls in GA's daily traffic numbers to see if your marketing attempts have any significant effects.
Now, after loading them all to your Data Warehouse, you can start modeling them, creating [relationships](/docs/relationships) properly for the data needs of your organization.
---
## Create a custom chart from Vega-Lite library
:::tip Before you start
For the concepts behind a custom chart definition (how fields, options, and the template fit together), see [Understand Custom Chart](/docs/charts/understand-custom-chart). For the full syntax, see the [AML Custom Chart reference](/reference/aml/custom-chart).
:::
This tutorial walks you through the steps to create a [Simple Bar Chart](https://vega.github.io/vega-lite/examples/bar.html) from [Vega-Lite library](https://vega.github.io/vega-lite/examples/). This is our expected result:
**Vega-Lite spec**
```json {3-7,12,16}
{
"data": {
"values": [
{"a": "A", "b": 28},
{"a": "B", "b": 55},
{"a": "C", "b": 43}
]
},
"mark": "bar",
"encoding": {
"x": {
"field": "a",
"type": "nominal"
},
"y": {
"field": "b",
"type": "quantitative"
}
}
}
```
**Holistics custom chart definition**
```aml {17,22,26}
CustomChartDef simple_bar_chart {
label: 'Simple Bar Chart'
fields {
field a {
type: 'dimension'
label: 'Category'
}
field b {
type: 'measure'
label: 'Value'
}
}
template: @vgl {
"data": {
"values": @{values}
},
"mark": "bar",
"encoding": {
"x": {
"field": @{fields.a.name},
"type": "nominal"
},
"y": {
"field": @{fields.b.name},
"type": "quantitative"
}
}
};;
}
```
## Step 1: Create a new template file
Go to **Development > Library > Custom Charts** and create a new `.chart.aml` file. Inside it, define your chart using a `CustomChartDef` block.
A definition is a reusable template. It does not hold any data itself. It has three parts: [`fields`](/reference/aml/custom-chart#fields) (required), [`options`](/reference/aml/custom-chart#options) (optional), and a [`template`](/reference/aml/custom-chart#template) (required). For a Simple Bar Chart, we only need `fields` and `template`.
## Step 2: Declare the fields
The fields are the slots users drag dataset fields into. To work out how many you need, look at the `encoding` of the Vega-Lite example: this bar chart maps one field to the x-axis and one to the y-axis, so we declare two.
```aml
CustomChartDef simple_bar_chart {
label: 'Simple Bar Chart'
fields {
field a {
type: 'dimension'
label: 'Category' // shown next to the field slot in Visualization Settings
}
field b {
type: 'measure' // fields marked 'measure' are aggregated automatically
label: 'Value'
}
}
}
```
For everything `fields` accepts, see the [fields reference](/reference/aml/custom-chart#fields).
## Step 3: Write the template
The `template` holds the Vega-Lite specification that renders the chart. Since the Vega team already wrote this spec, we can copy it from the [example](https://vega.github.io/vega-lite/examples/bar.html) and wire it up to Holistics data.
Two changes turn a static Vega-Lite spec into a dynamic Holistics chart:
- Replace the hard-coded `data` with `@{values}`, which receives the rows from the dataset at runtime.
- Replace each hard-coded field name with a `@{fields..name}` placeholder, so the chart uses whatever field the user drags in.
```aml {17,22,27}
CustomChartDef simple_bar_chart {
label: 'Simple Bar Chart'
fields {
field a {
type: 'dimension'
label: 'Category'
}
field b {
type: 'measure'
label: 'Value'
}
}
template: @vgl {
"data": {
"values": @{values} // receives the queried data from Holistics
},
"mark": "bar",
"encoding": {
"x": {
"field": @{fields.a.name}, // refers to the field dragged into slot a
"type": "nominal",
"axis": { "labelAngle": -45 }
},
"y": {
"field": @{fields.b.name}, // refers to the field dragged into slot b
"type": "quantitative"
}
}
};;
}
```
Save the file. The new chart type now appears in the chart picker alongside the built-in charts, ready for anyone in your organization to use.
:::tip
The `@{...}` syntax is string interpolation. It is how the template reads data and user input at runtime. For the full list of placeholders, see [Runtime variables](/reference/aml/custom-chart#runtime-variables).
:::
## Step 4: Build a report with the chart
Add a visualization, then pick your new **Simple Bar Chart** from the chart picker. Drag a dimension into the **Category** slot and a measure into the **Value** slot, and the chart renders:
:::note
Editing the `CustomChartDef` updates every report that uses this chart type, so changes to the definition propagate everywhere at once.
:::
## Where to find chart examples
Most custom charts start from an existing example rather than a blank template. These galleries are the best places to browse for a spec to adapt:
- **[Vega-Lite Example Gallery](https://vega.github.io/vega-lite/examples/)**: the catalog to start with. Each example links to an editable spec you can copy into a `@vgl` template, as we did above.
- **[Vega Example Gallery](https://vega.github.io/vega/examples/)**: lower-level and more flexible than Vega-Lite. Use these when you need fine-grained control and write your template with `@vg` instead of `@vgl`.
- **[Vega Online Editor](https://vega.github.io/editor/)**: paste a spec to preview and tweak it live before bringing it into Holistics. Handy for getting the visualization right first, then wiring in `@{values}` and field placeholders.
- **[Holistics Custom Chart Library](/docs/charts/custom-charts/library)**: ready-made `CustomChartDef` examples that are already wired up to Holistics data, so you can copy a full definition instead of porting a raw Vega-Lite spec. The source also lives on [GitHub](https://github.com/holistics/custom-chart-library).
When you copy a Vega-Lite spec, remember the two changes from [Step 3](#step-3-write-the-template): swap the hard-coded `data` for `@{values}`, and replace each field name with a `@{fields..name}` placeholder.
## Next steps
- Make your chart match your built-in charts in [Style a custom chart](/guides/style-custom-charts).
- [Make a custom chart interactive](/guides/create-interactive-custom-charts) so users can click and drag to filter.
- Add user-configurable styling controls with [options](/reference/aml/custom-chart#options).
---
## Create a custom chart (video walkthrough)
:::danger Legacy custom chart
This video shows the legacy **Admin Settings** workflow. The current approach defines charts as code with a `CustomChartDef` block. See [Custom Charts](/docs/charts/custom-charts) to get started.
:::
Below is a video tutorial of Custom Charts. The video covers:
- What Custom Chart is, and when you will need it
- A quick look into how Custom Chart works
- A hands-on example of building a Custom Chart from scratch.
---
## Make a custom chart interactive
Custom charts can have the same interactions as Holistics's built-in charts, such as:
- [Cross-filtering](/docs/cross-filtering)
- [Drill-through](/docs/interactions/drill-through)
- [Date drills](/docs/interactions/date-drills)
You enable this in two steps. First, you declare the interactions your chart accepts. Then you wire those interactions to Holistics features. This guide walks you through both.
## How interactions work
Interactions come from two parts of the chart template:
- **Selections** declare what a user can do, such as clicking a data point or dragging to select a range. You define them with Vega-Lite [`params`](/reference/aml/custom-chart#params). Vega-Lite calls a selection a *signal*.
- **`holisticsConfig`** wires those selections to Holistics features. Two fields matter:
- `crossFilterSignals`: selections that trigger [Cross Filter](/docs/cross-filtering) on **click**.
- `contextMenuSignals`: selections that open the context menu (Date-drill, Drill-through) on **right-click**. Use a **hover** selection here so the menu targets the point under the cursor.
For the full property reference, see [`holisticsConfig`](/reference/aml/custom-chart#holisticsconfig) and [`params`](/reference/aml/custom-chart#params). If you are new to chart templates, start with [Understand Custom Chart](/docs/charts/understand-custom-chart).
### Selection types
A selection is either point-based or interval-based.
**Point selection (click).** A point selection fires when a user clicks a single value (left click), or selects several values (hold Shift and left click). To deselect, the user clicks a selected value again. Read more in the [Vega-Lite selection docs](https://vega.github.io/vega-lite/docs/parameter.html#select).
**Interval selection (drag).** An interval selection fires when a user holds left click and drags across a range of values. To deselect, the user double-clicks anywhere on the chart.
## Interaction examples
Each example below starts from a working chart and adds one interaction. The full, copy-ready code for each one lives in the [Custom Chart Library](https://github.com/holistics/custom-chart-library/tree/main/interactive_features).
### Highlight selected columns on point selection
**Outcome.** When a user selects one or more columns, those columns stay fully visible while the rest fade out. On hover, a stroke outlines the column.
1. In your template, declare two point selections in `params`: one that fires on click, and one that fires on hover.
```aml
template: @vgl {
...
"params": [
// effective only while the user hovers
{ "name": "pointSelectionOnMouseOver", "select": {"type": "point", "on": "mouseover"} },
// effective when the user clicks
{ "name": "normalPointSelection", "select": "point" }
]
...
}
```
2. Add a `fillOpacity` rule so selected columns keep full opacity while the rest fade.
```aml
template: @vgl {
...
"mark": {
"fill": "#484848" // base color of the columns
},
"encoding": {
"fillOpacity": {
// selected columns stay fully visible
"condition": {"param": "normalPointSelection", "value": 1},
// unselected columns fade to 30%
"value": 0.3
}
}
...
}
```
3. Try it. Click a single column, then select a range by holding Shift and clicking. The selected columns should stay highlighted.
4. Add a `strokeWidth` rule so hovered columns get a light stroke and selected columns get a bolder one.
```aml
template: @vgl {
...
"mark": {
"fill": "#484848",
"stroke": "black" // stroke color
},
"encoding": {
"strokeWidth": {
"condition": [
// bolder stroke (2) on selected columns
{ "param": "normalPointSelection", "empty": false, "value": 2 },
// light stroke (1) on hovered columns
{ "param": "pointSelectionOnMouseOver", "empty": false, "value": 1 }
],
"value": 0 // no stroke by default
}
}
...
}
```
5. Try selecting and hovering again. You should see a light stroke on hovered columns and a bolder stroke on selected ones.
**Full code example:** [Highlight stroke effect on point selection](https://github.com/holistics/custom-chart-library/blob/main/interactive_features/highlight_stroke_effect_on_point_selection.md).
### Cross-filter on point selection
:::info What is Cross Filter?
Cross Filter creates shared filters that apply to all report widgets in your dashboard. To learn more, see [Cross Filter](/docs/cross-filtering).
:::
**Outcome.** When a user selects data on your chart, a cross filter for that selection is applied to every widget in the dashboard.
**Prerequisite:** a chart with a point selection declared. The [highlight example](#highlight-selected-columns-on-point-selection) above is a good starting point.
1. In your template, declare a point selection in `params`.
```aml
template: @vgl {
...
"params": [
// effective when the user clicks
{ "name": "normalPointSelection", "select": "point" }
]
...
}
```
2. Add a `holisticsConfig` field with `crossFilterSignals`, and list the point selection from step 1.
```aml
template: @vgl {
...
"holisticsConfig": {
"crossFilterSignals": ["normalPointSelection"]
}
...
}
```
3. Try it. Click a single column, or select a range with Shift and click. A cross filter should be created and applied to the other reports in your dashboard.
**Full code example:** [Cross Filter on point selection](https://github.com/holistics/custom-chart-library/blob/main/interactive_features/cross_filter_on_point_selection.md).
### Cross-filter on interval selection
**Outcome.** When a user drags to select an interval on your chart, a cross filter for that range is applied to every widget in the dashboard.
**Prerequisite:** a working custom chart you can add a selection to.
1. In your template, declare an interval selection in `params`.
```aml
template: @vgl {
...
"params": [
{
"name": "intervalSelection",
"select": {"type": "interval", "encodings": ["x"]}
}
]
...
}
```
2. Add a `holisticsConfig` field with `crossFilterSignals`, and list the interval selection from step 1.
```aml
template: @vgl {
...
"holisticsConfig": {
"crossFilterSignals": ["intervalSelection"]
}
...
}
```
3. Try it. Hold left click and drag across a range of values. A cross filter should be created and applied to the other reports in your dashboard.
**Full code example:** [Cross Filter on interval selection](https://github.com/holistics/custom-chart-library/blob/main/interactive_features/cross_filter_on_interval_selection.md).
### Cross-filter all sub-charts in a combo chart
**Outcome.** A selection on a combo (mixed) chart applies the cross filter to every sub-chart in the layer, not just the one the user interacted with.
**Prerequisite:** a combo chart built with a Vega-Lite `layer`. See [Cross Filter on sub-charts in a combo chart](https://github.com/holistics/custom-chart-library/blob/main/interactive_features/cross_filter_on_subcharts_combo_chart.md) for how to build one.
1. In your template, find the sub-chart in the `layer` array that the cross filter should also apply to.
```aml
template: @vgl {
...
"layer": [
// your bar chart definition
{ ... },
// your line chart definition (the one we'll filter)
{ ... }
]
...
}
```
2. Add a `transform` to that sub-chart with a `filter` that takes the user's selection as its `param`.
```aml
template: @vgl {
...
"layer": [
{ ... }, // your bar chart definition
{
"transform": [
// filter this sub-chart by the user's interval selection
{ "filter": {"param": "intervalSelection"} }
]
// ...the rest of the sub-chart definition
}
]
...
}
```
3. Create a cross filter on your dashboard and confirm the sub-chart updates along with the rest.
**Full code example:** [Cross Filter on sub-charts in a combo chart](https://github.com/holistics/custom-chart-library/blob/main/interactive_features/cross_filter_on_subcharts_combo_chart.md).
### Drill-through on point selection
**Outcome.** When a user hovers a data point and right-clicks, the Holistics context menu opens so they can Date-drill or Drill-through, just like on a built-in chart.
**Prerequisite:** a chart you can add a selection to (see the [cross-filter example](#cross-filter-on-point-selection)).
The context menu opens on **right-click**, so it must read the point currently under the cursor. That means it needs a **hover** selection, not the click selection you use for Cross Filter. (A click selection has no target on right-click unless the user left-clicks the point first, which would also cross-filter.)
1. In your template, declare a **hover** point selection in `params`. The `"on": "mouseover"` trigger makes it track whichever point the cursor is over.
```aml
template: @vgl {
...
"params": [
{ "name": "hoverPointSelection", "select": {"type": "point", "on": "mouseover", "clear": "mouseout"} }
]
...
}
```
2. Add a `holisticsConfig` field with `contextMenuSignals`, and list the hover selection from step 1.
```aml
template: @vgl {
...
"holisticsConfig": {
"contextMenuSignals": ["hoverPointSelection"]
}
...
}
```
3. Try it. Hover a data point and right-click; the context menu appears, with options to Date-drill and Drill-through.
Cross Filter and the context menu use different triggers, so wire them to different selections: a **click** selection for `crossFilterSignals` (left-click cross-filters the dashboard) and a **hover** selection for `contextMenuSignals` (hover, then right-click opens the menu on that point). For the full property reference, see [`holisticsConfig`](/reference/aml/custom-chart#holisticsconfig).
---
## Export BI report data to Python using Holistics API
Tired of wrangling big data datasets? Holistics allows you to export your prepared datasets to Python, using the Holistics API Python package. Let's get the most out of your business intelligence reports, by extending their usage to your Python scripts and applications.
VIDEO
Export BI report data to Python using Holistics API
---
## How to Filter Elements in ARRAY Fields
Many modern data warehouses support ARRAY data types (e.g. Snowflake, PostgreSQL, BigQuery, Redshift). This guide describes a practical workaround in Holistics to filter rows based on whether a specific value exists inside an array field.
:::info
This guide uses PostgreSQL syntax for its examples, but the concepts can be adapted to other databases that support array functions.
:::
## Use case
You have an `orders` table with a column like `applied_promo_codes` (ARRAY of text). You want a dashboard filter such as "promo code contains SAVE10" and have Holistics return all orders that include `SAVE10` in that array.

## Approach 1: Simple contains filter
Convert the ARRAY into a text representation and use Holistics’ built-in **contains** text filter.
```aml
dimension applied_promo_codes {
label: 'Applied Promo Codes'
type: 'text'
hidden: true
definition: @sql {{ #SOURCE.applied_promo_codes }}::text;;
}
```
Then apply a text filter with **contains** on `applied_promo_codes`.
**Limitation:**
The **contains** operator performs substring matching. For example, filtering on `PROMO10` may also match `PROMO100`.
If you require exact element matching, use Approach 2.
For available text filter operators, see: https://docs.holistics.io/docs/filters/text-filters#available-operators
## Approach 2: Delimiter-based filter (avoid false positives)
Choose a delimiter that is unlikely to appear in your element values (for example `#`). With it, the string becomes `#PROMO10#`, so searching for `#PROMO10#` won't match `PROMO100`.
To ensure exact matches, wrap each array element with delimiters (i.e. `,`, `#`) before converting it to text. This prevents partial matches such as `PROMO10` matching `PROMO100`.
```aml
dimension applied_promo_codes {
label: 'Applied Promo Codes'
type: 'text'
hidden: true
definition: @sql concat('#', array_to_string({{ #SOURCE.applied_promo_codes }}, '#'), '#');;
}
```
Create a parameter to capture the desired promo code value:
```aml
param array_value_param {
label: 'Array Value'
type: 'text'
}
```
Then filter using a delimiter-aware `LIKE` condition on `applied_promo_codes`, ensuring only exact promo code matches are returned.
```aql
model.applied_promo_codes LIKE concat('%#', model.array_value_param | first(), '#%')
```
Apply this as an AQL condition at the widget or metric level, and map a dashboard filter to `array_value_param`.
---
## Getting Started
New to Holistics or just starting your trial? Start here!
* **[What is Holistics?](https://holistics.io/guides/what-is-holistics)**
* **[What Data Builders can do with Holistics](https://holistics.io/guides/what-data-builders-can-do-with-holistics)**
* **[What Data Consumers can do with Holistics](https://holistics.io/guides/what-data-consumers-can-do-with-holistics)**
---
## Handling Nested JSON Data in Holistics
Often times when you load data from NoSQL source into Holistics, the data will be stored as JSON/nested object and nested array. This document outlines different approaches for working with them effectively.
:::info
This guide is intended to be database-agnostic, but for simplicity's sake, we will use PostgreSQL syntax for demonstration. For other syntaxes: [BigQuery](https://www.holistics.io/blog/how-to-extract-nested-or-array-json-in-bigquery/) and [MySQL](https://www.holistics.io/blog/how-to-extract-nested-json-data-in-mysql-8-0/)
:::
If you use MongoDB and haven't loaded the data into a SQL database, please check out this [MongoDB Reporting guide](/guides/mongodb-reporting.md).
## High-level Approach
JSON objects typically have 2 types of data structures that we need to deal with:
- Non-array JSON
- JSON array
If you are **dealing with non-array JSON**, the solution can be either:
- Create **Custom Field** that accesses the specific value inside the nested object, or
- Flatten the nested object into a separate model using Holistics **SQL Query Model**
If you are **dealing with a JSON array**, the solution is to unnest the array into a separate table, and join them along with the original table. We can do this using Holistics **SQL Query Model** feature as well.
## Handling non-array JSON objects
**With with non-array JSON**, we can either:
- Create **Custom Field** that extracts specific values from the JSON object, or
- Flatten the nested object into a separate model using **[Holistics SQL Query Model](/docs/query-models.md)**.
### Creating Custom Field for a specific JSON property
Using Holistics Modeling, you can create a Custom Dimension that references inside the non-array JSON column to the property you want. This field will then be exposed to others to use.
In the example below, we extract the `height` field out from field `size`.
```json
{"h":14,"w":21,"uom":"cm"}
```
Formula for field height: `({{ #THIS.size }} ->> 'h')::numeric`
Here is a step-by-step guide to create custom fields from **JSON** objects in **Holistics 4.0**.
Given the column **info** that contains JSON objects storing order information. Your data may look like this:
```json
{ "customer": "Raju Kumar", "items": {"product": "coffee", "qty": 6}}
```
1. **(Optional)** Create a [Table Model](/reference/aml/table-model) from your table.
2. In your model, extract JSON properties into Custom Fields using this syntax.
```json
// Extract the first field, customer's name
dimension name {
label: 'Name'
type: 'text'
hidden: false
definition: @sql ({{ #SOURCE.info }} ->> 'customer') ;;
}
// This is used to access nested item
dimension order {
label: 'Order'
type: 'text'
hidden: false
definition: @sql ({{ #SOURCE.info }} -> 'items' ->> 'product') ;;
}
// This syntax is used to cast your data into numeric values
dimension quantity {
label: 'Quantity'
type: 'text'
hidden: false
definition: @sql ({{ #SOURCE.info }} -> 'items' ->> 'qty')::numeric ;;
}
```
3. Your result should look like this.
Click to see full code example
```json
// public_orders.model.aml
Model public_orders {
type: 'table'
label: 'Orders'
description: ''
data_source_name: 'demo'
dimension name {
label: 'Name'
type: 'text'
hidden: false
definition: @sql ({{ #SOURCE.info }} ->> 'customer') ;;
}
dimension order {
label: 'Order'
type: 'text'
hidden: false
definition: @sql ({{ #SOURCE.info }} -> 'items' ->> 'product') ;;
}
dimension quantity {
label: 'Quantity'
type: 'text'
hidden: false
definition: @sql ({{ #SOURCE.info }} -> 'items' ->> 'qty')::numeric ;;
}
table_name: '"public"."orders"'
}
```
### Flattening your nested JSON into a separate table
A second approach would be writing a SQL model to hold your flattening logic, this basically creates a new, derived table from the original table with the flatten fields.
```sql
select
{{#inv.id}},
({{#inv.size}} ->> 'w')::numeric as width,
({{#inv.size}} ->> 'h')::numeric as height,
({{#inv.size}} ->> 'uom') as unit
from {{ #inventories_proper inv }}
```
Given that your **orders** table contains a column **info** that is of JSON type storing order information. Let's flatten **order_info** column into a separate **[Query Model (Query Model)](/reference/aml/query-model)**, **order_info**.
1. Create a [Table Model](/reference/aml/table-model) from your table if you haven't already.
2. **(Important)** By default, the auto-generated **JSON** field will have the type **composite**. As **Holistics 4.0** does not support this data type at the moment, change the type of your **JSON** field into **text**.
3. Let's build a [Query Model (Query Model)](/reference/aml/query-model) from this **Table Model**. The syntax for this model is as below.
```sql
select
({{ #public_orders1.info }} ->> 'customer') as name,
({{ #public_orders1.info }} -> 'items' ->> 'product')as product,
({{ #public_orders1.info }} -> 'items' ->> 'qty')::numeric as quantity
from {{ #public_orders1 }};;
```
4. You should be able to successfully create the `order_info` **Query Model** that contains the flattened **JSON** object.
Click to see full code example
```json
// order_info.model.aml
// This is a Query Model
Model order_info {
type: 'query'
label: 'Order Info'
description: ''
data_source_name: 'elephant'
dimension name {
label: 'Name'
type: 'text'
hidden: false
definition: @sql {{ #SOURCE.name }};;
}
dimension product {
label: 'Product'
type: 'text'
hidden: false
definition: @sql {{ #SOURCE.product }};;
}
dimension quantity {
label: 'Quantity'
type: 'number'
hidden: false
definition: @sql {{ #SOURCE.quantity }};;
}
owner: 'demo@holistics.io'
query: @sql
select
({{ #public_orders.info }} ->> 'customer') as name,
({{ #public_orders.info }} -> 'items' ->> 'product')as product,
({{ #public_orders.info }} -> 'items' ->> 'qty')::numeric as quantity
from {{ #public_orders }};;
models: [
model__public_orders
]
}
```
## Working with array JSON field
When you have an array JSON field, what we should do is to **normalize the data,** by bringing the JSON array into a proper relational table.
For example, you have a table model containing blog posts and the comments made to it. The `tags` field is a JSON array:
Let's say you want to know **"How many blog posts are the for each tag?".**
In this case, you will need to write a SQL model (let's call it `posts_tags_unnested`) to unnest the array so that each tag value is placed on a separate row. Post ID values will be repeated:
```sql
select
{{#p.id}} as post_id,
tags_unnested #>>'{}' as tags
from
{{ #posts p}},
json_array_elements({{#p.tags}}) as tags_unnested
```
Add relationships, and then create a dataset out of the original model and the newly created SQL model. Note that the relationship between `posts.id` and `posts_tags_unnested.post_id` is **many to one:**
To answer the question: "How many blog posts there are for each tag?", simply drag in the unnested **Tags** field and **count distinct [Post ID]** at the Dataset Explore screen.
Here's the full animated video of the steps:
Given the column **info** that contains JSON objects storing information of people's posts. Your data may look like this:
```json
'{ "author": "Helen", "tags": ["home-grooming","beauty"]}’
'{ "author": "Mary", "tags": ["healthy","lifestyle"]}’
'{ "author": "Bob", "tags": ["healthy","lifestyle"]}’
```
Note that this JSON has a `tags` field that contains a JSON array. Let's transform the data so that we can answer the question: *"How many blog posts there are for each tag?"*.
1. Create a [Table Model](/reference/aml/table-model) from your table if you haven't already.
2. **(Important)** By default, the auto-generated **JSON** field will have the type **composite**. As **Holistics 4.0** does not support this data type at the moment, change the type of your **JSON** field into **text**.
3. Next, extract the **JSON** array from the **tags** field into a **Custom Dimension** using this syntax below.
```json
dimension tags {
label: 'Tags'
type: 'text'
hidden: false
definition: @sql
select
({{ #SOURCE.info }} -> 'tags') as tags;;
}
```
4. Let's build a [Query Model (Query Model)](/reference/aml/query-model) to unnested the values in the JSON array of the newly-created dimension above. The syntax for this model is as below.
```sql
select
{{ #public_posts.id }} as post_id,
tags_unnested #>>'{}' as tags
from
{{ #public_posts }},
json_array_elements({{ #public_posts.tags }} :: json) as tags_unnested
```
5. Finally, create a **Dataset** that contains the **Query Model** created in step 5 `post_info_unnested.model.aml` and the original **Table Model** in step 1 `public_posts.model.aml`.
Then, set-up the relationship between `posts.id` and `posts_info_unnested.post_id` to be **many to one** by adding this [RelationshipConfig](/reference/aml/relationship#relationship-config) into the **Dataset** definition.
```sql
relationships: [
RelationshipConfig {
rel: Relationship {
type: 'many_to_one'
from: r(public_posts.id)
to: r(post_info_unnested.post_id)
}
active: true
}
]
```
1. Let's see this in action. To answer the question: "*How many blog posts there are for each tag?*", simply drag in the **unnested Tags** field and **count distinct [Post ID]** at the Dataset Explore screen.
Click to see full code example for the Table Model used in the tutorial
```json
// public_post.model.aml
// This is the Table Model
Model public_posts {
type: 'table'
label: 'Posts'
description: ''
data_source_name: 'elephant'
dimension id {
label: 'Id'
type: 'number'
hidden: false
definition: @sql {{ #SOURCE.id }};;
}
dimension info {
label: 'Info'
type: 'text'
hidden: false
definition: @sql {{ #SOURCE.info }};;
}
dimension tags {
label: 'Tags'
type: 'text'
hidden: false
definition: @sql
select
({{ #SOURCE.info }} -> 'tags') as tags;;
}
owner: 'demo@holistics.io'
table_name: '"public"."posts"'
}
```
Click to see full code example for the Query Model used in the tutorial
```json
// post_info_unnested.model.aml
// This is the Query Model
Model post_info_unnested {
type: 'query'
label: 'Post Info Unnested'
description: ''
data_source_name: 'elephant'
dimension post_id {
label: 'Post Id'
type: 'number'
hidden: false
definition: @sql {{ #SOURCE.post_id }};;
}
dimension tags {
label: 'Tags'
type: 'text'
hidden: false
definition: @sql {{ #SOURCE.tags }};;
}
owner: 'demo@holistics.io'
query: @sql
select
{{#p.id}} as post_id,
tags_unnested #>>'{}' as tags
from
{{ #public_posts p }},
json_array_elements({{#p.tags}}) as tags_unnested;;
models: [
model__public_posts
]
}
```
Click to see full code example for the Dataset used in the tutorial
```json
// posts.dataset.aml
// This is the Dataset
Dataset posts {
label: 'Posts'
description: ''
data_source_name: 'elephant'
models: [
post_info_unnested,
public_posts
]
relationships: [
RelationshipConfig {
rel: Relationship {
type: 'many_to_one'
from: r(public_posts.id)
to: r(post_info_unnested.post_id)
}
active: true
}
]
owner: 'demo@holistics.io'
}
```
---
## How to rename a field name or field values?
Some things that happen commonly are that you want to rename a field or rename the field values; so that they can be displayed as end-user friendly results.
In this guide, I will walk you through the approaches to do that.
## Case #1: Rename or change a field name
You could customize field labels to affect how field names appear in the *Reporting tab; the change does not affect the database itself.*
*For example*, you want to display label of legends as **`A`, `B`, `C`** instead of 'A Running Total', 'B Running Total' and 'C Running Total’.
There are two ways to change them:
### Approach #1.1: Edit field labels when data modelling
You can edit a model field and change its default field label in the reports.
### Approach #1.2: Select `Custom Label` when editing a report
You can mouse over a field and click on it to reveal the drop-down menu and select ‘Custom Label’ to change the field label for that specific report.
> ***Note:*** For customizing label of the measure which is created by using an aggregate function in the UI, we can only use approach #1.2
>
## Case #2: Rename or label values within a field
For example, I want to change the gender labels below to something friendlier such as Female or Male
### Approach #2.1: Create a custom dimension in the data modelling layer
You could create a custom dimension and use `CASE...WHEN` operator to change how the values are displayed.
> ***Note:*** When applying this approach, we recommend hiding the original field so that users don’t select the field that is not renamed.
>
### Approach #2.2: Use business calculation in reporting tab
Add a business calculation in the report and use the `case when` operator to rename the data labels only for the current reports.
:::caution Note:
For the cases that the fields have list of many values which is not human friendly to display in reports, our recommendation is:
1. Create and import a mapping table with values of the mapping (old field values, display values)
2. Define the relationship that joins this mapping table with original table (1-n) to get the display value.
:::
## Pros/Cons
- If you change the field label or field values when data modelling, the result is more reusable that can be selected across all reports. However, this can only be done by users who have access to Data Modeling Layer
- In case you want to customize them when editing the report, the result is only for that specific report. Nevertheless, any users with Explorer Role can do that.
---
## Working with Pipedrive's custom fields
In [A Complete Guide to Analyze Pipedrive data with Holistics for Free](https://www.holistics.io/blog/a-complete-guide-to-analyze-pipedrive-data-with-holistics-for-free/) we have gone through the basics steps to work with Pipedrive data. However, things are more complicated when **custom fields** are involved.
In this guide we will walk you through the steps to handle Pipedrive's custom fields, assuming that you are:
* Familiar with how Import Models, Query Models and Datasets work.
* Familiar with SQL.
* Comfortable with viewing source HTML of webpages (optional).
## The problem with Pipedrive's custom field
For each entity (deals, organization, persons...) Pipedrive comes with some default data fields that are enough for basic use cases. These default fields are automatically recognized by Holistics and can be imported in a straightforward way.
Things get a little bit hairy when you have custom fields in Pipedrive to serve your specific needs, for example a "Prospect Classification" field where you define if the lead is good or bad, or a "Deal Sources" field to note all the channels that you used to connect with your customers.
These fields are not automatically recognized, and the fields' values come in raw form that does not resemble what you see in Pipedrive.
## How to import custom fields
When creating Import Model from a Pipedrive's table, open **Sync Configuration** section and scroll through the whole list of default fields to see **Add New Column** option:
Click on the button and a new blank field will appear. From here you can input the custom field's API key and a user-friendly field name to be used in the model.
To have the API key of the field, you need to log in your Pipedrive account and navigate to **Setting > Custom fields** section. You will see the default fields are in black, while the custom fields are in blue and have API key that looks like the product of an office cat walked through a keyboard:
Click on the field's name and copy its API key to the **Source Column** field in Holistics's import screen, then name it however you like. In this example, we name the new field `prospect_fit_tag`. and set the field's data type as Text.
By default, custom fields from Pipedrive will be imported as Text. Click **Create** to start importing as usual.
If your custom field is of numeric, text, date... type then your data will appear as it is.
However, the work is just half-done if your custom field is **single-option or multiple-option type.** The field's options are presented as a string of numbers:
For example, In the `prospect_fit_tags` column, instead of seeing verbose labels like "Good Fit", "Bad Fit"..., what you see **are the options' IDs.**
Next we will look at how to translate these IDs into user-friendly textual values.
### Find out the text labels of option IDs
When importing custom single-option and multiple-option fields, Pipedrive does not automatically return the verbose field values as you see in your user interface. Instead, they return the IDs of those values.
We have to find out ourselves which value each ID corresponds to by **inspecting the webpage elements** itself with the browser's **developer tool**.
Head back to **Custom field screen and click on the field that you want to examine. Right-click on one of the field's options, then select Inspect** (if you use Chrome) **or Inspect Element** (if you use Firefox).
The inspection panel will open up, with the element for the option highlighted. As you can see in this example, there are three `` elements corresponding to the three options on the web page.
In this example, you can see that ID 552 corresponds to "Bad Fit", ID 553 corresponds to "Good Fit" and so on.
We admit that this step may look intimidating if you are not familiar with HTML, but we have not found a faster way to do it. If you happen to know a more user-friendly way to do this, please let us know!
### Handle single-option custom fields
Now that we know the textual value of those option IDs, we can now create a calculated field in the Pipedrive model to do translation.
Head back to Holistics, navigate to your **Pipedrive Deals** model, create a Calculated Field and use the `CASE... WHEN...` clause to translate the IDs to more verbose values.
In the case of a single-option field like **Prospect Fit, each deal corresponds to only one prospect fit value so** `CASE... WHEN...` should work.
However, in the case of multiple-option fields like Deal Source, you need other tricks.
### Handle multiple-option custom fields
In the case of **Deal Source** field, each deal can have multiple values. How will you answer questions like "how many deals are there from different sources?"
In other words, this is a typical problem with "nested fields".
To properly handle it, you need to "unnest" this field so that each row only contains one value. It means that if the deal has two values, there should be two rows for that field, and so on.
Head back to the Data Modeling page. Now we need to write a SQL transformation that does the nesting. You can see in the result now each deal has two rows corresponding to two different deal source values:
In this example, we are using BigQuery. The exact query will be different in other SQL flavors like PostgreSQL, but the logic remains the same.
Click Save, and now you have a new Query Model (let's name it `unnested_deal_sources`). Next, set up a**n n - 1** relationship between this model and the original `pipedrive_deals` model to use it in the exploration dataset.
Go back to your Deals dataset, add the newly created model to your dataset and drag in the unnested field from this model.
Finally you can count the number of deals coming from each source. In this example, we have four deals but three of them came from inbound trial requests:
---
## Importing data from Google Sheets
:::caution Important
Please note that this feature is currently not supported for **Holistics 4.0**. See note 1 in [Feature Comparison docs](/docs/product-versions/3.0-vs-4.0) for more information.
:::
## Introduction
**Holistics 3.0** allows you to import data from Google Sheets into your data warehouse for BI & reporting purposes. In this tutorial, we will show you step-by-step on how to do that.
## High-Level Approach
The general workflow to import data from Google Sheets is:
1. Prepare the Data Warehouse in which Holistics has been granted additional [WRITE permission](/docs/connect/create-db-user#read-only-or-write-permission) to import data.
2. Create an Import Model that links to the Google Spreadsheet
3. Grant Holistics permission to connect to your Google account.
4. Configure sync settings
5. Save & Finish.
Behind the scenes, Holistics connects through Google Sheets API, download the data and load into a table in the data warehouse. That table is then exposed as a data model in the Holistics modeling layer.
## Step-by-step Instructions
### 1. Prepare your Data Warehouse
You should already connected to your Data Warehouse. Make sure your datawarehouse also have [WRITE permission](/docs/connect/create-db-user#read-only-or-write-permission).
Then go to Modeling and make sure the data warehouse is selected.
### 2. Create an Import Model that links to the Google Spreadsheet
- First, click on **Create Data model**, choose **Data Import** and select **Google Spreadsheets**.
- Then, connect to Google Spreadsheet by following these steps:
- Paste the URL of your Google Spreadsheet into the Google Source URL box.
- Click **Validate** and select the spreadsheet you want to connect.
- Now, you can preview the structure of the destination table before proceeding.
### 3. Grant Holistics permission to connect to your Google account
If this is the **first time you connect to a spreadsheet**, you will be prompted to grant Holistics permission to connect to your Google Account.
The popup will show only one time since one Holistics account can only link to one Google account for all imports.
:::info Note
Please make sure that Holistics's popup is allowed in your browser for the authentication to work.
:::
✍If you want to **link to a new Google account**:
- First, you need to remove Holistics app in your old Google account. Visit this [page](https://support.google.com/accounts/answer/3466521?hl=en) for **Remove third-party account access.**
- Then just paste the URL and click Validate again. The pop-up will show to ask you to reconnect.
:::info Note
Please note that the old imports might fail if the new account does not have permission to access them.
:::
### 4. Advanced Settings
From Advanced Settings you can modify the destination table from Destination Settings, and control how column types will be cast from Sync Configuration. Please visit the dedicated page for more details.
- **Destination Settings:**
:::info **COMMON GOT-CHAS**
- Make sure that you've set the Destination Table to a schema with WRITE access. If you've changed it, remember to click "Apply"
- Right now, Holistics forces normal text for the Destination Table Name, that's why you must ensure the target table name is lowercase
:::
- **Sync Configuration:**
Holistics could map your data to one of the Generic Data Types first (Whole Number, Decimal, TrueFalse, Date, DataTime, Text) by default. Remember to select the suitable data types in the Sync Configuration section before starting loading your data.
Finally, click **Create**. Now you have your Google Sheets data in Holistics for you to analyze, merge with your database data, and visualize on your dashboards.
## Other Notes
### Prepare your Google Sheets
Formatting requirements:
- Remove extra headers and footers.
- The sheet needs to be in a tabular format, have a header row with column names
- The sheet can not have any rows with non-empty cells after the actual data rows end. The typical example of this is a "Totals" row at the end, summing up each column.
- Ensure the correction of data structure by cleaning values that don't match the data type specified.
- Resolve cell errors before importing the data.
- Change the decimal separator. If your Google Sheet uses a comma (`,`) for the decimal separator, data may not accurately be brought into Holistics. The issue is Holistics expects the decimal separator to be a period (`.`). By opening your Google Sheet and changing the locale of your spreadsheet (**File** > **Spreadsheet settings**) to United States, for example, it will change the decimal separator to a period.
### Date formatting in Google Sheets
In order to successfully import dates to Holistics, the date formats need to be either:
- `YYYY-MM-dd`
- `MM-dd-YYYY`
If you are using specific Date formats in Google Sheets that are not formatted in a way that works with Holistics, there are two approaches to deal with this case:
1. Change the date format of your date column to `YYYY-MM-dd` or `MM-dd-YYYY` from your Google Sheet file.
2. Save your date column as String and when each time query you need to `cast` that value to Date with the right format to be read by Holistics.
### Refresh source
After adding new columns or removing any column to the imported Google Sheet, you need to re-validate the sheet's link following these steps:
1. Click on the **Data modeling** tab and go to the model with the imported Google Sheet
2. Click on the **Manage** button on the right panel and **Re-validate** the URL. Note that this will also revert all of the column names and data types to default.
### Refresh the Data Warehouse
When you create a new schema in your Data Warehouse to store Google Sheet import, remember to refresh the Data Warehouse that allows choosing this new schema as a destination for your Google Sheet data.
You can follow the instruction below:
1. Go to Tools, choose **Data Manager**
2. Select the data source
3. Click **Refresh**
## Limitation
- Currently, Holistics doesn't support [incremental/upsert mode](/docs/storage-settings#incremental-mode) for Google Spreadsheet.
You will need to either use Full/Append import mode or if you want still to do incremental/upsert import, you could do it via other external tools like Fivetran or Stitch.
- There is currently no way to import several tabs at once. If your Google Sheet has multiple sheets/ tabs, we recommend importing each tab individually.
## Troubleshooting
#### ERROR: "failedPrecondition: this operation is not supported for this document"
When I import a Google Spreadsheet or export to Google Spreadsheet, I encounter this error:
`failedPrecondition: this operation is not supported for this document`?
This error happens normally because the file was saved in .xlsx extension and you need to converted it to Google Sheet format by choosing the option "Save as Google Sheets".
#### To avoid causing any harm to our data, I want to grant Holistics READ-ONLY access to the database but still be able to import data to it
Our recommended course of action is to create a designated schema in this database replica that does allow for WRITE access so you can specify that Holistics writes the Google Sheet to only this schema.
If your team gives [WRITE access](/docs/connect/create-db-user#read-only-or-write-permission) only for this schema and keeps other schema access as READ-ONLY, Holistics will only be allowed to import tables and store query model tables in this schema, and the operation fails if it attempts to select any other schema for the table destination.
Otherwise, your team needs to set up their own Extract-Load (EL) on your end to make this Google Sheet available in your database for querying.
#### (Redshift Only) I get the error 'stl_load_errors' when loading the sheet into the database
The most likely reason is that your original Google Sheet data is not clean and some value does not match the data type specified. However, the exact error will need to be observed from the `stl_load_errors` table.
For more details, you can also run this simple query on your end:
```sql
SELECT * FROM stl_load_errors;
```
Please help to check and correct the data on your end then try to create a plain new import model.
---
## Interact with Dashboard
A static dashboard does not seem to be useful for business users - what if they want to dig deeper into the numbers instead of viewing passively? That's where Holistics's Exploration function comes in.
## Data Exploration
At this moment, Holistics supports two primary exploration actions with Dashboard: Date Drill and Explore.
However, please note that you at least need to have Explorer role to interact with the Dashboard shared with you, Viewers and Public users (view Dashboard via Shareable Link and Embed Link) can only view the Dashboard and cannot explore it.
### Date Drill
When a visualization has a date/date-time axis, you can drill at different time levels (year, month, date, hour, minute...).
For example, a user views sales amount broken down by year could drill down to see the same metric broken down by quarter/month/day...
Simply right-click on the chart → select a period → the chart will show the values aggregated up to that time period.
### Explore Data
Explore Data give users the flexibility to dig deeper into the data, ask questions and make adjustments to reports without Analysts' support.
Here I just simply right-click on any chart/table and click **Explore**. Then, a pop-up with drag and drop interface will be shown so that I can change the visualization preferably:
Please note that changing the visualization in this view does not affect the report you are exploring unless you choose to override it. After you satisfy with your exploration result, you can click **Save**.
For more detailed information, please refer to our docs: [Explore data](/docs/data-exploration)
## Communication with team
You can comment directly on reports/dashboards to ask questions, provide information or discuss the results. Anyone with access to a Report/Dashboard can see its comments.
To comment on a Dashboard, click on the Comments button to open the comments panel, add a comment by typing into the text box at the bottom of the panel.
To reply to a comment, click **Reply**.
## Get the most updated data
To maximize the performance of the dashboard and reports when presenting data, the result-set of your reports will be stored in our cache for some time (24 hours by default).
If you find the presented data in your Dashboard too obsolete, you can **manually refresh** the cache to get the most updated data from your database.
Please note that to avoid high load to your database, we only allow **admins** and **dashboard's owners** to refresh the dashboard or its' widgets inside. For now, Explorers, Viewers, and Public users cannot use this operation.
### Refresh Dashboard
Simply click on the **Refresh** button next to the Dashboard's title to refresh the whole Dashboard.
### Refresh Widgets
If you only need to refresh certain widgets, click on more option in the widget and refresh.
## Other actions
### Zoom in/out
You can zoom in/out the chart to see more details of the data
### Expand Widget
If you find the visual of a Widget too small, you can also expand it to have a larger view.
### Move Widget
You can also re-arrange the widget's position as your preference.
### Duplicate Widget on the same dashboard
If you want to create a new widget on the current dashboard that is only slightly different from the existing one (for example, different conditions, different aggregation levels... but same metrics), you can click on **More** menu, select **Duplicate** and make changes to the new widget.
### Clone widget to another dashboard
At the moment, you can clone a widget from a dashboard to another one by following these steps:
1. Explore the Widget
2. Click **Save**
3. Select the dashboard that you want to save the widget, and click **Save** to finish
A new widget will appear in that dashboard with the same visualization with your original widget.
---
## Holistics Guides
Welcome to Holistics Guides! These are use-case specific guides to help you achieve certain outcomes.
With Holistics, you get a self-service & data modeling platform covering each step of the data value chain. From **Data Reporting**, **Data Modeling**, **Data Integration** to **Data Delivery**, Holistics lets you easily manage analytics from your databases and data systems, **giving you full control of your raw data**.
**You connect your data sources, we'll provide the software solution**. Holistics does not store your data, and instead connects to and uses your databases. This allows you to **scale up your data infrastructure whenever you require**.
Remember to [invite your team to join you on the platform](https://secure.holistics.io/manage/users), to get more done together. Let's get started!
---
## How to join data/tables in Holistics?
To join tables in Holistics, you have 2 options:
1. Define a virtual join using [Relationships](/docs/relationships)
2. Create a [Transform model](/docs/query-models.md) (a combined table) with a SQL statement that joins 2 tables
---
## Make a custom chart responsive
A responsive chart fills its available space and redraws when that space changes: when a user resizes the widget on a dashboard, switches to a phone, or drags the panel divider in a report. A chart with a fixed pixel size, by contrast, stays the same size no matter how much room it has, leaving empty gutters or clipping at the edges.
Holistics automatically resizes every custom chart to fit its widget and redraws it when the widget changes, for both Vega-Lite (`@vgl`) and Vega (`@vg`). You do not wire up any resize handling. The work, when there is any, is making sure the chart looks right at every size, which mostly means not pinning its contents to fixed pixel values. The details differ slightly between the two languages.
## Make a Vega-Lite chart responsive
Vega-Lite charts are responsive out of the box. Holistics tells Vega-Lite to fit the widget, so as long as you do not hard-code a size, the chart stretches to fill the widget and redraws when it changes. To keep that working:
* **Omit `width` and `height` from the spec.** Holistics already fits the chart to the widget. The moment you set `"width": 400`, the chart locks to 400px and stops stretching. The same goes for `height`. Most of the library's `@vgl` charts (like the [diverging bar chart](/docs/charts/custom-charts/library/diverging-bar-chart)) set no size at all.
* **Use `"width": "container"` if you copied a sized spec.** Many examples from the [Vega-Lite gallery](https://vega.github.io/vega-lite/examples/) ship with a fixed size. Either delete those properties or set them to `"container"` so the chart reads its widget's dimensions:
```aml {2-3}
template: @vgl {
"width": "container",
"height": "container",
"data": { "values": @{values} },
"mark": "bar",
"encoding": { ... }
};;
```
**Limitation:** Multi-view Vega-Lite layouts (`facet`, `row`, `column`, `concat`, `repeat`) lay out their panels at a natural size and do not stretch to the widget, so they do not fill it. For a responsive grid of small multiples, build it in Vega instead (the [faceted sparkline](/docs/charts/custom-charts/library/faceted-sparkline) does exactly this).
## Make a Vega chart responsive
Holistics keeps a `@vg` chart's `width` and `height` signals in sync with the widget (resizing on both window and container changes) and adds an autosize that keeps the axes inside, so the canvas already tracks the widget. The chart relayouts to fit as long as its scales and marks read those signals. It looks stuck only when its scale ranges use fixed numbers, so the marks stay one size while the canvas around them resizes.
### The starting point: a fixed-size chart
Here is a plain Vega bar chart whose scales use fixed pixel ranges. Holistics still resizes the canvas to the widget, but the bars stay pinned to 400px wide because the ranges are hard-coded numbers.
```aml {30,38}
CustomChartDef responsive_bar {
label: 'Responsive Bar'
fields {
field category { label: 'Category' type: 'dimension' }
field value { label: 'Value' type: 'measure' }
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"width": 400,
"height": 300,
"data": [
{
"name": "table",
"values": @{values},
"transform": [
{ "type": "formula", "expr": "datum['@{fields.category.name}']", "as": "category" },
{ "type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount" }
]
}
],
"scales": [
{
"name": "x",
"type": "band",
"domain": { "data": "table", "field": "category" },
"range": [0, 400],
"padding": 0.1
},
{
"name": "y",
"type": "linear",
"nice": true,
"domain": { "data": "table", "field": "amount" },
"range": [300, 0]
}
],
"axes": [
{ "orient": "bottom", "scale": "x" },
{ "orient": "left", "scale": "y" }
],
"marks": [
{
"type": "rect",
"from": { "data": "table" },
"encode": {
"update": {
"x": { "scale": "x", "field": "category" },
"width": { "scale": "x", "band": 1 },
"y": { "scale": "y", "field": "amount" },
"y2": { "scale": "y", "value": 0 }
}
}
}
]
};;
}
```
The highlighted lines are the problem: each scale's `range` is a fixed number, so the marks cannot grow. (Holistics overrides the top-level `width`/`height` above, so they do not pin the chart, but it is cleaner to drop them.)
### Make it responsive
Bind each scale's `range` to the widget with Vega's `"width"` and `"height"` shorthand instead of fixed numbers:
```aml {6,14}
"scales": [
{
"name": "x",
"type": "band",
"domain": { "data": "table", "field": "category" },
"range": "width",
"padding": 0.1
},
{
"name": "y",
"type": "linear",
"nice": true,
"domain": { "data": "table", "field": "amount" },
"range": "height"
}
]
```
`"width"` and `"height"` are the signals Holistics drives from the widget, so the bars now follow it and redraw on resize. (The `"height"` shorthand also flips the y range to run bottom-to-top for you, so larger values sit higher.) You can also drop the top-level `"width"`/`"height"` while you are here, since Holistics overrides them. Save the file and resize the widget on a dashboard to see it reflow.
Here is the full definition after both changes:
Complete responsive definition
```aml
CustomChartDef responsive_bar {
label: 'Responsive Bar'
fields {
field category { label: 'Category' type: 'dimension' }
field value { label: 'Value' type: 'measure' }
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"data": [
{
"name": "table",
"values": @{values},
"transform": [
{ "type": "formula", "expr": "datum['@{fields.category.name}']", "as": "category" },
{ "type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount" }
]
}
],
"scales": [
{
"name": "x",
"type": "band",
"domain": { "data": "table", "field": "category" },
"range": "width",
"padding": 0.1
},
{
"name": "y",
"type": "linear",
"nice": true,
"domain": { "data": "table", "field": "amount" },
"range": "height"
}
],
"axes": [
{ "orient": "bottom", "scale": "x" },
{ "orient": "left", "scale": "y" }
],
"marks": [
{
"type": "rect",
"from": { "data": "table" },
"encode": {
"update": {
"x": { "scale": "x", "field": "category" },
"width": { "scale": "x", "band": 1 },
"y": { "scale": "y", "field": "amount" },
"y2": { "scale": "y", "value": 0 }
}
}
}
]
};;
}
```
### Adjust the layout with derived signals (optional)
The shorthand stretches the plot to the full widget. When you need a margin around the plot, or a layout you compute from the size, you do not read the widget yourself. Holistics already maintains the `width` and `height` signals, so reference them from your own derived signals:
```aml
"signals": [
{ "name": "plot_width", "update": "width - 50" }, // leave room for a wide y-axis
{ "name": "cell_width", "update": "width / 3" } // a 3-column small-multiple grid
]
```
Use those signals in your scale ranges or mark positions. The [faceted sparkline](/docs/charts/custom-charts/library/faceted-sparkline) builds its whole grid this way, deriving a per-cell size from `width` and `height`.
## Test that it actually resizes
A chart can look fine at its default size and still remain fixed. To confirm it is responsive:
1. Add the chart to a dashboard and drag the widget's corner to resize it. The chart should follow, not leave a gap or clip at the edges.
2. Preview the dashboard at a narrow width (or open it on a phone) and check that nothing overflows horizontally.
3. If the bars or marks do not resize (in `@vg`), look for scale ranges or mark sizes that use fixed numbers instead of the `"width"`/`"height"` shorthand.
## Next steps
- [Style a custom chart](/guides/style-custom-charts) to match the look of Holistics's built-in charts.
- [Make a custom chart interactive](/guides/create-interactive-custom-charts) so users can click and drag to filter.
- Browse the [Custom Chart Library](/docs/charts/custom-charts/library) for ready-made responsive templates you can copy.
---
## How to create a Custom Map
## Introduction
Your company might have hyper-local geographical data that Holistics has not supported (see: [Supported location formats](/docs/charts/map/supported-location-formats)). However, you can still build a beautiful custom map by uploading and using your own data.
## Custom map requirements
To create a Custom map in Holistics, you need two components:
1. A GeoJSON file that contains the location name and polygons to draw on the map. If you only have a JSON file, follow these steps to convert it to GeoJSON:
- Visit [https://geojson.io](https://geojson.io/)
- Sign in with your Github account
- On the top left, select Open > File > Choose the JSON file on your computer. The file will be uploaded and you'll see the location data being visualized immediately. Below is a typical JSON file format and display.
- On the top left, select Save > GeoJSON and download the GeoJSON file to your computer. You will later upload this file to Holistics.
2. Your dataset must contain the location fields. Please note that the location names in your dataset must match exactly that of the GeoJSON file. The locations whose names do not match will not be displayed on the map.
You need to have Admin role to be able to upload the Custom map data to Holistics.
## Creating a Custom map in Holistics
### Sample data
- For demonstration purposes, we'll try to visualize the population of Alabama's counties. (Data source: [Github](https://github.com/topojson/topojson) You can download the already converted GeoJSON file here.
- Create a dataset using our [sample data](https://docs.google.com/spreadsheets/d/1-W-ybn2dFM9cjvhRuJYrVCEkprQ42nY18gDfN07kwDY/edit?usp=sharing) on Google Sheet here. Let's name our dataset Alabama_pop.
### Steps
#### 1. **Upload GeoJSON file**
- In Holistics, on the top right corner, select Your Tenant name > Organization Settings > More Settings. At the Settings screen, navigate to Custom Map.
- Click on Add a Custom Map to start uploading your GeoJSON file. Please note that Holistics only supports GeoJSON format and a maximum file size of 10MB at the moment.
- After uploading the GeoJSON file, you'll be presented with a few settings:
- **Map name**: How you want to call this map. This will help you differentiate the maps in the Settings screen. Let's name our map Alabama-population.
- Select the field that contains the location name data: When you upload the file, Holistics will scan all the properties in the file and list them by each row. You just need to find the property/row that contains the location name data (i.e. United States, Singapore...) - you can see this in the Sample data. In our example, let's go ahead and select NAME (Sample data is Morgan, Randolph...).
:::note
Why do you need this step? By selecting the property that contains the location names, you are telling Holistics to use this property as a location type. Later on, when building the map, you'll define the location field in your dataset with this location type so Holistics can draw the map precisely.
:::
- **Display name**: After selecting the NAME property, let's change the Display name into County Name so we can easily recognize it later. Then click Submit and this step is complete.
#### 2. **Build Custom map in Viz Settings**
- Navigate to our dataset Alabama_pop, then select the Visualizations type Filled Map.
:::note
Holistics supports building Custom map for Filled map only.
:::
- Drag field Name into Location. The field will auto-expand so you can select the Location type. Since we have already specified the Location type to be County Name in the previous step, let's go ahead and select Use Custom Map > County Name.
- In Value field, we select Population as the input. Then click Get Result. Voila!
#### 3. Customize the Custom map
Before we customize our Custom map, let's add Ruc Code into Legend. In Alabama, some counties will share the same Ruc Code. We can use this to color these counties later on. Then click Get Results again.
Navigate to Style tab. There are two sections here:
* **3.1. Map display settings**
- Show color scale: To display/hide the color scale at the bottom right of the map.
- Color scale layout: To display the color scale horizontally or vertically.
- Map background: To change the map's layer display. Holistics supports 5 options: Light, Dark, Street, Satellite, Outdoor.
* **3.2. Color Formatting**
- This section allows you to customize this map's color. We can color this map by each county's name, Ruc Code, or population. Let's go with Population (value) for now as it's the most common to color by value.
- **Color formatting options**
- **Style**: Click on the color scale to change the color style of the map. Choose **Smooth** to generate a color gradient for both the map and the scale. Choosing **Steps** to explicitly divide the color scale into color blocks, each of which
will contain a range of values. For example, if our data ranges from
0-100, and we set 5 steps, there will be 5 corresponding blocks of
color: 0-20, 20-40…
- **Min - Mid - Max**: by default, these fields’ values will be set to **Auto**. However, you can select from the drop-down list to the left of the input field to change to **Number** or **Percent**. Then, you can type your own data and the changes will be applied immediately to the color scale in **Style**.
Note: If your input data in **min** or **max** is out of range (i.e. your data range is 1-100 but you input 101 in **min**), Holistics will choose the max color as the displaying color. If your input data in **mid** is out of range (i.e. you put 1000 in **mid**), Holistics will not make any change to the color scale.
- After we tweak with the settings, here's our final Custom map:
- If you want to color the map by Legend, remove Population (value) from the Color formatting option, then choose Ruc Code (Legend). By default, Holistics will display all the locations in yellow.
- Let's try to color the counties with Ruc code 6. Select the small colored square to the right of 6, then change it into Blue. We will see the results immediately.
## Improving map precision
Sometimes, Holistics will not recognize one or more of the location names in your data.
When this happens we will display a small notice modal on the lower right of the preview window. Hovering on this will help you understand which location names we failed to recognize.
This happens when the location name in your dataset does not match correctly the location name in the GeoJSON file. For example, in your dataset, the United States is stored as the U.S, but in your GeoJSON file, it is stored as United States. Although they are technically the same, Holistics cannot match them.
To fix this, change the location name in either your data source or the GeoJSON file. This will fix the problem.
---
## How to create a Filled Map?
## What is a Filled Map?
A Filled Map is a type of map that displays polygon shapes corresponding to geographical borders, such as country borders or state borders. Each shape on the map is filled with a distinct color based on the value it represents.
## Types of Filled Map
### Choropleth Map
Choropleth Map is a type of Filled maps where regions are colored based on a numerical value, typically ratio data such as per-capita income or population density.
### Distribution Map (Coming soon)
Distribution Maps are Filled maps but the regions are colored based on a string value, most typically a subject such as an animal, religion, or political parties.
### Custom Map
Custom Maps are designed to support dataset that contains geographical data type that Holistics has not supported (i.e. Zipcode, Counties, districts, wards...) (see more: [Supported location formats](/docs/charts/map/supported-location-formats))
[Learn how to create custom map](/guides/map/create-custom-map.md) like the map of Alabama's counties' population below.
## Types of Fields
- **Location (required)**: This field indicates the specific geographic positions where the data points are located.
- **Legend**: The legend field provides additional information or categories to distinguish and classify the data points on the map. It can be used to assign colors, symbols, or labels to represent different groups or types of data.
- **Value**: The value field represents a numerical or quantitative attribute associated with each data point.
- **Conditions**: The condition field allows for the application of filters to selectively view data points based on specific criteria or requirements.
## Creating a Filled Map
:memo: **Goal**: Following these steps to visualize the number of COVID-19 cases in the US by states:
### Sample Data
* Dataset: [Google Sheet link](https://docs.google.com/spreadsheets/d/1FuCFuaGMGGqnvMA3BRhl5Zf5KDrW62o3uvfW3_6uMJ0/edit?usp=sharing) (updated 27th July 2020)
**Step 1**: Select the filled map icon from the visualizations pane.
**Step 2**: In the Viz settings, **drag and drop** your desired location field into the **`Location`** section.
Once you drag the field, a dropdown will appear, asking you to assign a specific location type for that field. This step is **NOT optional,** we recommend doing it to enhance the precision of the map. You can learn more about improving map precision in Holistics **[here](/guides/map/improve-map-precision)**.
For example, if your field is named **State**, you can select **State/Province** from the dropdown.
:::tip
Your location data should be in the form of full-text country names or abbreviation. For more information, see [Supported location formats](/docs/charts/map/supported-location-formats).
:::
**Step 3**: Click on **`Get Results`** to view the map. By default, all locations will be colored light orange.
**Step 4 (Optional)**: If you want to change the global color or assign different colors to each location, navigate to the **`Styles`** section. Select your location field and click on the colored box next to each location to modify its color.
To color regions by a number (like cumulative COVID cases) instead of a flat fill, switch to a [Choropleth Map](/guides/map/create-filled-map#choropleth-map).
Now that your Filled Map can visualize all the states in the US, let's highlight the **cumulative number of COVID cases across all states**. To generate a Choropleth Map, follow these steps:
**Step 1**: Input your location data into the **`Location`** field
**Step 2**: Then, input a numerical value into the **`Value`** field.
**Step 3**: Click **`Get results`**.
## Color Formatting Options
To customize the map's formatting, navigate to the **`Styles`** tab and follow these options:
- Click on **`Set Color By…`** to choose a field to apply color to your map. Once you've selected a field, you'll see the following options:
- Click on the color scale in **`Style`** to change the color scheme of the map.
- Select **`Smooth`** to generate a color gradient for both the map and the scale.
- Choose **`Steps`** to divide the color scale into blocks, each representing a range of values.
For example, if the data ranges from 0-100 and you set 5 steps, there will be 5 corresponding color blocks: 0-20, 20-40…
- **`Min`** - **`Mid`** - **`Max`**: By default, these fields are set to Auto, but you can change them to Number or Percent from the drop-down list on the left of the input field. Once changed, enter your own values, and the color scale in the Style section will immediately reflect the updates.
:::info Note
If your input data for min or max is out of range (e.g., your data range is 1-100, but you input 101 in min), Holistics will use the maximum color as the displayed color. If your input data for mid is out of range (e.g., you enter 1000 in mid), Holistics will not make any changes to the color scale.
:::
## Improving map precision
Sometimes, Holistics will not recognize one or more of the location names in your data.
When this happens we will display a small notice modal on the lower right of the preview window. Hovering on this will help you understand which location names we failed to recognize.
This may happen if there is a location or abbreviation that Holistics does not recognize, mostly because of typos, wrong formats, or the fact that such location exists in 2 different places (i.e. "Paris" is both the city of France and the city of Texas, USA)
To improve the map precision, see [How to improve map precision in Holistics](/guides/map/improve-map-precision.md)*.*
---
## How to create a Geo Heatmap (Legacy)?
:::info
From 26th March 2021, we have released an [improved version of Heatmap](create-heatmap.md). You can still use this document if you need to edit, explore and save any Heatmap created before this day (which we will refer to as *Legacy Heatmap*). Visit [this document](https://www.holistics.io/blog/whats-new-in-heatmap/) to learn more about the changes in this new version.
:::
Geographic Heat Map (**Geo Heatmap** for short) is an interactive visualization that displays your data points on a real map and signifies areas of low and high density.
To create a Geo Heat map, the following fields are required:
* **Latitude**: The latitude value of your data point
* **Longitude**: The longitude value of your data point
* **Weight**: The weight, or "intensity" of the point.
* **Label**: Label to be displayed on the tooltip when you click on a point's market
Latitude and Longitude are compulsory fields, while Weight and Label are optional.
# Styling options
* **Radius**: The radius of influence of each point
* **Max Intensity**
* **Show Markers:** Toggle this on to show markers on the data points.
* **Auto Center**: Toggle this on to center the map and zoom out automatically so all data points are visible.
---
## How to create a Heatmap?
## What is Heatmap?
Heatmap is an interactive visualization that displays your data points on a real map and signifies areas of low and high density.
In Holistics, you can use Heatmap to:
- Visualize the density of an object (i.e. how many stores, events...) in certain areas
- Visualize the magnitude of value of an object (i.e. how high is the temperature, revenue, population...) in certain areas
## Types of Fields
- **Latitude and Longitude:** represent the geographical coordinates of each location.
- **Value**: The value field represents a numerical or quantitative attribute associated with each data point.
- **Conditions**: The condition field allows for the application of filters to selectively view data points based on specific criteria or requirements.
## Creating a Heatmap
:memo: **Goal**: Following these steps to visualize the population density of the United States and to answer the question: "How is the US population distributed geographically?"
### Sample Data
* Dataset: [Google Sheet link.](https://docs.google.com/spreadsheets/d/1gOtr6I1fCvz6GyvL7y9POjPIbLa9cuBmz6BtsCFRqno/edit?usp=sharing)
### Step 1: Select the Heat Map icon from the visualization pane
### Step 2: Define the Location
In the Viz settings, drag or type your latitude and longitude values into the **`Latitude`** and **`Longitude`** fields, respectively.
A lat/long-only heatmap shows where points *cluster*. To weight it by a value instead (Step 3), drag a numeric field like `Population` into the **Value** field.
If you click on **`Get Results`**, Holistics will display a heatmap that represents the **density of cities in the US,** which is informational but might not directly answer our question.
DEMO GIF HERE
### Step 3: Visualize the Population Density
You can also **visualize the population density**, drag `Population` to the **`Value`** field, then click **`Get Results`**.
To customize the color of the population density:
### Step 4: Customize the Heat Map
To make the Heatmap more visually appealing, you can customize the Heatmap with three options:
- **Map background**: Choose how the base layer of the map is displayed. Holistics has five options: Light, Dark, Street, Satellite, and Outdoor.
- **Opacity**: Adjust the strength of the color display.
- **Intensity**: Control the size of the colored areas displayed.
For example, we want to find out "Why is the US population distributed mostly in the mid-east of part of the country?"
We will change the map background to Satellite, and reduce the opacity (so the color does not hide the geographical characteristics of the map).
### Final Result
We can immediately see that the mid-eastern part of the US is characterized by mountains and plateaus, making it less inhabitable.
---
## How to create a Point Map?
## What is Point Map?
Point maps plot geographic latitude/longitude data to visualize the location of data on a map. The point is identified by either a value or a subject.
## Types of Fields
- **Latitude and Longitude**: represent the geographical coordinates of each location.
- **Legend**: The legend field provides additional information or categories to distinguish and classify the data points on the map. It can be used to assign colors, symbols, or labels to represent different groups or types of data.
- **Value**: The value field represents a numerical or quantitative attribute associated with each data point.
- **Conditions**: The condition field allows for the application of filters to selectively view data points based on specific criteria or requirements.
## Creating a Point Map
:memo: **Goal**: Following these steps to visualize the listings of Airbnb homestays in Asheville (North Carolina, USA):
### Sample data
* Dataset: [Google Sheet link](https://docs.google.com/spreadsheets/d/1I7SGRCXWpYjHlkqCePZvBtNRFp2WxXPP7X4QOCXoOe8/edit?usp=sharing).
### Step 1: Select the Point Map icon from the visualization pane
### Step 2: Define the Location
In the Viz settings, drag or type your latitude and longitude values into the **`Latitude`** and **`Longitude`** fields, respectively.
If you click on **`Get Results`**, Holistics will display a plain one-color map with equal-sized bubbles.
### Step 3: Add Statistical Meaning
To add statistical meaning to your map, include a numeric measure such as `Price` in the **`Value`** field. Click on "Get results." You will notice two changes:
- The bubble sizes change based on the values they represent. Larger values result in bigger bubbles.
- The color of each bubble changes, with greater values being represented by a "greener" color.
### Step 4: Customize the Map
To improve the appearance of your map, go to the **`Styles`** tab and make the following adjustments:
- **`Color scale layout`:** Choose between vertical or horizontal positioning of the color scale.
- **`Map background`**: Select from five options: Light, Dark, Street, Satellite, or Outdoor.
- **`Point size`**: Adjust the size of the points on the map using values from 1 to 10.
- **`Size scale`**: Modify how the data is calculated by choosing between Linear or Logarithmic scaling.
### Step 5: Customize Bubble Colors
To further customize the colors of the bubbles, under **`Color Formatting`** section:
- Click on **`Set Color By…`** to choose a field to apply color to your map. Once you've selected a field, you'll see the following options:
- Click on the color scale in **`Style`** to change the color scheme of the map.
- Select **`Smooth`** to generate a color gradient for both the map and the scale.
- Choose **`Steps`** to divide the color scale into blocks, each representing a range of values.
For example, if the data ranges from 0-100 and you set 5 steps, there will be 5 corresponding color blocks: 0-20, 20-40…
- **`Min`** - **`Mid`** - **`Max`**: By default, these fields are set to Auto, but you can change them to Number or Percent from the drop-down list on the left of the input field. Once changed, enter your own values, and the color scale in the Style section will immediately reflect the updates.
:::info Note
If your input data for **min** or **max** is out of range (e.g., your data range is 1-100, but you input 101 in **min**), Holistics will use the maximum color as the displayed color. If your input data for **mid** is out of range (e.g., you enter 1000 in **mid**), Holistics will not make any changes to the color scale.
:::
### Final Result
With these steps, you can create your final point map.
---
## Improve map precision in Holistics
Sometimes Holistics will not recognize one or more of the location names in your data. When this happens you will see a similar error banner at the bottom left of the map preview panel. Hovering on this banner will show you the list of all the unshown locations.
*Note: this will only be visible in the data exploration view.*
## Troubleshooting
There are three main reasons Holistics might not be able to recognize your data.
### The location might exist in multiple places
This problem happens when your dataset contains a location name that can exist in multiple countries. A few examples of this problem are:
* "Georgia" is a country in Eurasia but also a state of the United States
* "Paris" is a city of France (we all know this), but also a city of Texas state in the United States
To help Holistics recognize your location data better, the first step is to choose a location type for the data.
For demonstration purposes, we will use a sample dataset of COVID-19 cases in the US for this example. We have uploaded the dataset to a [Google Sheet link](https://docs.google.com/spreadsheets/d/1FuCFuaGMGGqnvMA3BRhl5Zf5KDrW62o3uvfW3_6uMJ0/edit?usp=sharing) if you want to download and try for yourself.
Without any configuration, our initial map (from this dataset) will look like this:
As you can see, all the states in the US are colored except Georgia. In this case, Holistics recognizes [Georgia as a country in Eurasia](https://en.wikipedia.org/wiki/Georgia_(country)) (to the south of Russia) rather than [a state of the US](https://en.wikipedia.org/wiki/Georgia_(U.S._state)).
In order to fix this, we should "tell" Holistics that all of our location data is "State" rather than "Country".
In particular, after we drag our location field `State` onto **Location**, Holistics will automatically expand a dropdown listing all the location types for you to choose from. As we want to define our data as "State", we will select "State/Province".
As you can see, Holistics support 4 location types in the following hierarchical order: Continent, Country, State/Province, and City.
These fields will help you narrow down the exact region of the location data, so Holistics can precisely detect and display it on the map.
> **Quick tip:** The more location data you provide, the more precise your map. Remember to define a location type for all the data you drag in.
If you have already selected a location type in the list for one field (in our example "State/Province", then the next location field you drag in will only be presented with the 3 other types (in our example "Continent", "Country" and "City").
In our example, we will not need to configure anything further. However in some cases if you decide to input more than one location field, remember to select the one you want to visualize on the map. By default, Holistics will choose the first location field you dragged in.
Let's click **Get Results** and see what we get.
Awesome! Our Georgia state is back! Now we can start adding fields to add more meaning to the map.
### Holistics Geojson library has not supported your location data
You only upload the GeoJSON that draws the map, not your business data (sales by country, profit per district, and so on).
If your business operates on a hyper-local scale, the chance is you possess very complicated and specific geographical data of some regions which Holistics have not added to our library.
To check if our library has already supported your data, please try to search for some location names in Holistics' [Supported location formats](/docs/charts/map/supported-location-formats).
If we have not supported your location data, don't worry. We have designed Custom Maps so you can upload your own GeoJSON file and build unique maps with your geographical data. For more information, see [how to create a custom map](/guides/map/create-custom-map.md)
### Your data may contain typos, or written in a different format from its name in our database.
In this case, please follow these steps:
* Refer to Holistics' [Supported location formats](/docs/charts/map/supported-location-formats)
* Search for the location name that was not visualized on the map. You can hit **Command/ + F** and type the location name/ location code you have
* See if we have already supported that location or that we're supporting a different format name. If no results are returned, it means we have not supported your location data. Please refer to Problem 2.
* Modify your data so that the location name(s) match our database format
\---
If the above solutions did not work for you, let us know at [product@holistics.io](mailto:product@holistics.io)!
---
## Reporting with MongoDB
:::warning Deprecation
From May 2022, the support for importing data from MongoDB has ended. Please check out the announcement and suggested solution [here](https://docs.holistics.io/faqs/el-deprecation).
:::
This document shows how you can set up reporting when you're using MongoDB as your primary data source.
## High-level Approach
Our bias is that MongoDB are not well-designed for analytics purpose, we recommend you to set up a SQL data warehouse, and use an ELT tool to load data into the warehouse.
The general steps are as follows:
1. Setting up a SQL database as data warehouse
2. Load data from MongoDB into SQL data warehouse (EL)
3. Transform data to unnest nested fields and nested arrays
4. Perform reporting operations off the SQL database
## Step-by-step Guide
### 1. Setting up SQL data warehouse
If you don't already have a SQL data warehouse, you will need to set up one. Refer to [this page](/docs/connect/dont-have-sql-database.md) for guides on how to set up SQL databases as data warehouse.
After spinning up SQL data warehouse, please connect them to Holistics.
### 2. Load data from MongoDB into your SQL data warehouse
Holistics has a **built-in EL(T) functionality to pull data from MongoDB to SQL database**. After that, users can easily query and create dashboards/reports easily using SQL.
Use the Import Models functionality to load individual MongoDB collections into data warehouse tables. It will automatically create the tables in your SQL datawarehouse, and pull the MongoDB data into it.
### 3. Transform nested fields and nested arrays
MongoDB deal with a lot of nested fields/arrays. Please refer to the section below on how to work with nested fields/arrays in MongoDB.
[Handling nested/JSON data in Holistics](/guides/handle-nested-json.md)
### 4. Build reports and visualizations
Now that you have loaded MongoDB data into SQL database, you can use Holistics Reporting to aggregate and visualize the data.
And put them together into a dashboard.
---
## Calculate Dynamic Point-In-Time Metrics
## What is a Point-in-Time Metric?
**Point-in-Time** (PIT) is a time metric that allows users to understand a value at a particular time. When you run a Point in Time report with a specific date, you will get a "snapshot" of what your data looked like when that date occurred. Point-in-time can be the start of a time period, the end of a time period, or any point in between.
## The Business Scenario
Imagine you have the metric value of entities that last for a period of time. Each change in the value is recorded as a separate line and tagged with a start and end date of the period in which the record was current.
**ID | Metric value | Valid from | Valid to**
***Here is a specific example:***
Summarize the metric value at a point in time (a month, week, or day), and have it play nicely with **dynamic date periods and ranges** on interactive dashboards.
In a reporting model for bank information, we have a table of credit contracts. Each contract has multiple components and has a start and maturity date.
Most commonly, banks would like to see the outstanding balance to their customers at month ends. In this case, the bank should run an **End of month report** that **sums up** all the outstanding balances of all customers at the last day of each month.
## Getting in the details
Assume you work with this simple table `pit_raw` which records all of your `customer_id` and their `pipeline_value` during the active period (between `start_date` and `end_date`)
**Requirement:**
- Your business users want to see the total `pipeline_value` for all `customer_id` on the single date (**end of whatever period selected**) where it falls between `start_date` and `end_date`
- Ability to interact with a dynamic axis which can be set to week, month, quarter, or year, so the end of the period will change depending on what is selected there.
**For example**, you want to have something that looks like this which calls "**End of the period pipeline value**". By using Metrics Sheet Visualization, you can use the "Show metrics by" function, so the end of the period will change depending on what is selected there.
## High-level Mechanism
The solution needs to be dynamic so you can aggregate & report the `sum` value of total `user_id` on the last day of whatever selected period (week, month, year...), without summing all the values of days within the range.
With all that in mind, here is one example way to work all these things together using our SQL transformation model and some Holistics out-of-the-box visualization features that you can follow:
1. First, simply transform the original data from records with 2 dates to the right data format with time dimensional based value, from which the user can select a particular date.
For each unique (`user_id`, `pipeline_value`) combination, you'll have a row for each day the entity was valid (between the combination's `start_date` and`end_date`), and then you can just aggregate over the date to get your answer.
2. Set target logic using Transform model and Business Calculation in your dataset exploration.
- Basically, you will need a simple `daily_sum` model to aggregate the total values of all `customer_id` on each day. The model also needs to contain additional columns which enable you to identify the **values** on the **last day** in whatever selected period and make them get the **maximum value possible** in the period.
- Create a dataset and use Business Calculations with aggregation of `max` to calculate the **End-of-period** sum of the total values.
3. Use **Metrics Sheet Visualization** or other charts which is enabled for **Date-drill** features. These will help you to quickly change the time granularity (year, quarter, month, date) of reports.
## Step-by-step Instructions
### High-level Transformation Diagram
As it's not a simple transformation, we break them down into multiple steps with interim charts. The diagram below puts the steps together:
### Step 1: A time spine for the metric
In this step, we'll focus on expanding the valid start-end date range to multiple rows containing dates within the range and store them into `pit_prep (user_id, pipeline_value, valid_date)`
A sample output table would look like this:
```
**| user_id |pipeline_value |valid_date|**
| A | 500 |2021-02-14|
| A | 500 |2021-02-15|
| A | 500 | ... |
| A | 500 |2021-06-04|
```
To do that, we need to follow 2 main steps:
- Generate a date series that contain all possible dates (between minimum `start_date` and maximum `end_date` in your table) (using PostgreSQL's `generate_series` function)
- Take your entities of the original table (`user_id, pipeline_value`) and join that date series on the condition that the date is between the entity’s `start_date` and `end_date`
The query:
```sql
-- date ranges for [start_date, end_date]
with date_range as (
select
generate_series( min({{ #a.start_date}}), max({{ #a.end_date}}),'1d')::date as valid_date
from
{{#pit_raw a}}
)
select
{{#a.customer_id }},
{{#a.pipeline_value }},
date_range.valid_date
from
date_range
left join {{#pit_raw a}}
on date_range.valid_date >= {{#a.start_date}} and date_range.valid_date <={{#a.end_date}}
```
### Step 2: Create Daily Sum model
2.1. Create SQL query model `daily_sum (valid_date, sum_value, row_number)` with which simply the total `pipeline_value` of all `user_id` for each day.
- The additional column `row_number` enumerates the rows in the sort order defined by `valid_date`. With this field, using the `max` aggregate function gets us the rows of the last day for each period.
```sql
select
{{ #a.valid_date }},
sum({{ #a.pipeline_value }}) as sum_value,
row_number()over(order by {{ #a.pipeline_date }}) as row_number
from
{{ #pit_prep a}}
group by 1
order by 1
```
2.2. In `daily_sum` model, create 2 custom dimensions (`sum_plus_rnx10bn`, `rnx10bn`) as below:
```sql
Model daily_sum {
--Based Field
field valid_date date
field sum_value integer
field row_number integer
-- Custom Field
rnx10bn = "(row_number*10000000000)"
sum_plus_rnx10bn = "(row_number*10000000000+sum_value)"
}
```
Since you want to be able to swap in different levels of granularity of the End-of-period metrics in your report, you need to make the last value in whatever selected period get the maximum value possible in the period.
Based on the 2 custom dimensions, create a new field with aggregation of `max` in your report which takes it down to the last day's value in the period selected.
- *Note: 10 billion is an arbitrarily large value here to extend the row numbering to where it shouldn't ever touch the daily quantity value*
### Step 3: Create Business Calculations for End-of-period metrics
Sounds like your data is modeled properly, add the `daily_sum` model to the `Point_in_time`dataset and it will be ready to be explored.
Create a **business calculation** with aggregation of `max` call `End of period value`, which takes it down to the total values of the last day in the period selected in the visualization.
```sql
End_of_period_value = max(sum_plus_rnx10bn) - max(rnx10bn)
```
### Final step: Building the chart
Now you can throw any of the **End-of-period metrics** and other aggregated metrics together in a report using the `valid_date` field as an axis.
To get them to play nicely with dynamic date periods and ranges for interactive dashboards, you can apply Metrics sheet visual or other charts that are enabled for Date-drill features.
- By applying **Metrics sheet** visual, you can use **Show Metric by (**month, quarter, year...) and **Number of Columns** in visualization settings to change Aggregation Period and Time range will be shown in the chart.
- On the other hand, we also can apply other charts which are enabled for **Date-drill** features such as Line, bar, area, column... chart.
---
## Calculate Running (Cumulative) Total via SQL
:::info
Holistics has supported a native feature to calculate Running Total. Check out [our documentation here](/docs/running-total).
:::
## Introduction
A running total is a common metric to gain insight into how an amount has accumulated over time. In this article, we'll show you how to set up cumulative number (running total) in Holistics.
## Use Case
We'll use a simple table `running_total_demo_data` that contains 3 fields: `date`, `sale`, and `product`. There are 1379 rows, dates range from Jan 2016 to Jun 2020 with no sales made on certain days, prices are random positive values, and the product is assigned randomly between A/B/C.
Given the above demo data, we want to build 2 reports:
- Running total of all sales (unsegmented).
- Running total of sales broken down by individual product (segmented).
## High-level Solution
Currently, Holistics doesn't support native running total calculation, so we need to do some transformations using SQL queries to calculate the running totals.
We will create two query models that calculate running totals as follows:
- We create `rt_all (ordering, date, sales, running_all)` where *running_all* is the cumulative sum of all products' sales.
- We create `rt_products (ordering, date, product, sales, running_by_product)` where *running_by_product* is the cumulative sum of each product's sales.
To perform the cumulative calculation, in this guide we'll be using the self-join technique instead of window functions technique. Read on for more details.
## Transforming Data
1. First, we'll prep the data by adding a numbered `ordering` field to each row and have `rt_prepped` model.
2. To calculate running total, we self-join the model back on itself, using the `ordering` and `product` fields as the join conditions to calculate the cumulative field `running_total` and save it to new models as below.
1. We build `rt_all` model: running total by date
2. We build `rt_products` model: running total by product and date
### Adding `ordering` column
Our first step will be to create an `ordering` column. Besides being simpler for a lot of `JOIN`-related transformations, it's also easier for humans to read (especially when timestamps between transactions are too similar).
We create a Query Model (which we'll name `rt_prepped`) with this simple code:
```sql
select
row_number () over () as ordering,
date,
sales,
product
from
{{ #running_total_demo_data}}
```
`ROW_NUMBER()` written in this way generates a sequential column that numbers each row. You can add an `ORDER BY` argument within the `OVER()` clause if you have another column that you would like to order it by.
> **Note:** If you are recording both positive and negative value transactions, you might want to use a `DATE_PART()` function to extract the dates based on the intervals of your choice (e.g. 'month' and/or 'year'), `SUM()` the values, and then group them by your chosen interval so that there is only one entry per interval. You will also have to pre-filter the segments you wish to exclude or segment your report by.
>
### Building Running total by Date (Unsegmented)
Now, let's add a new cumulative column to tell people how total prices of all products do we have up to a particular day and save it to `rt_all` model
**Query:**
We are simply joining each row on its precedent and summing price totals to create our `running_all` field. Creating the `ordering` field makes my first JOIN condition easier. Without it, you might need to create a joining key.
```sql
SELECT
t1.ordering,
t1.date,
t1.sales,
sum(t2.sales) as running_all
FROM
{{ #rt_prepped t1}}
INNER JOIN {{ #rt_prepped t2}} ON t1.ordering>= t2.ordering
GROUP BY
t1.ordering,
t1.date,
t1.sales
ORDER BY
t1.id
```
### Building Running total by Product & Date (Segmented)
Sometimes, we need to calculate the running total based on date by product.
In SQL, this might not be ideal as it would create separate running totals for each `product` type and you will have to do extra work to create an overall running total in your visualizations, but with Holistics it can actually cut down on your work!
Let's add second JOIN conditions to segment our data by the `product` field and then proceed.
```sql
SELECT
t1.ordering,
t1.date,
t1.product,
t1.sales,
sum(t2.sales) as running_by_product
FROM
{{ #rt_prepped t1}}
INNER JOIN {{ #rt_prepped t2}} ON t1.ordering>= t2.ordering AND t1.product = t2.product
GROUP BY
t1.ordering,
t1.product,
t1.date,
t1.sales
ORDER BY
t1.ordering
```
Both segmented and unsegmented models have their time and place when it comes to generating reports.
### Caveat: Handle Days With No Data
There's a problem with the above models: days with no sales are not shown in the result table (e.g. no sales between Jan 07, 2016 and Jan 31, 2016). This is considered a bug in our query, and needs to be addressed. We do want that particular day to report 0 sales, instead of missing out on the value completely.
We fix this by creating a date dimensions model which generates a list of all dates between `[min(date), max(date)]` (using PostgreSQL's `generate_series` function) and then `FULL JOIN` it to the base model on the `date`/`datetime` field. You then further clean the data by either changing your `price` and other fields with `CASE WHEN` conditions to populate them with 0/'NIL' where appropriate.
Now, let's edit `rt_prepped` model with a more complicated SQL query as below:
```sql
with date_range as(
select
generate_series( min({{ #a.date}}), max({{ #a.date}}),'1d')::date as dates
from
{{ #running_total_demo_data a}}
)
select
row_number () over () as ordering,
date_range.dates as date,
coalesce(t1.product,'A') as product,
coalesce(t1.sales,0) as sales
FROM
{{ #running_total_demo_data t1}}
full join date_range on t1.date=date_range.dates
```
## Visualizations and Reporting in Holistics
Let's walk through how to present the data using different types of charts.
### Area charts
For a simple area chart, drag the `Date` field into the "X-Axis" area, and click on it to select your interval. We've chosen "Quarter".
Drag your segment `Product` into the "Legend area.
Drag your `Running By Product` field to your "Y-Axis" area, and click on it and select "Max".
*As stated above, if you are recording both positive and negative transactions, you will have to pre-group and sum your transactions by your chosen interval, or else the visualization might be inaccurate.*
Under "Styles", enable "Stack Series".
Of course, you can always use the unsegmented version of your table.
### Displaying using Pivot Tables
For segmented data in a pivot table, drag your `Date` to "Rows", your segments to "Columns" (in order of hierarchy), and `Running All` to values set to Max. Go to "Styles" to enable your Row Totals. Now you have all your running totals by segments and as a whole.
As with the Area Chart, if you want the unsegmented running total without restriction on your Date interval, either prep your data accordingly or use the unsegmented data.
### Combination Chart
Just like the area chart, `Date` as your "X-Axis", segments in your legend, and "Max" `Running Total` as one of your Y-Axes with any other relevant metrics in other Y-Axes.
### Dashboard Filters
You can also create both selective and global filters on your Dashboard with a few clicks. This allows your business user to see only the data that is relevant to them without needing to run to an analyst to generate a whole new SQL query for each new report.
For our example, we've created a "Product" filter that affects only the segmented charts, and a global "Date" filter.
## Summary
As you saw, in this guide we have shown you how to do a simple cumulative report using the power and flexibility of Holistics data modeling and visualization. We address a few points:
- Calculate running total without window functions. Instead of that, `INNER JOIN` data model back on itself.
- Avoid empty-row days with generated date ranges
- The accumulating sums don't only sum for the selected date period. It also takes into account the running total from the beginning of time.
---
## Setting goal/target in Holistics
## Introduction
Currently, Holistics does support goal setting directly, but you can still visualize your metrics and goals using the current mechanisms. In this article, we will walk you through the steps to do so, from data preparation to visualization.
## Context
In this article, we will use the classic e-commerce dataset, but the principle is the same if you are in other business domains. Suppose your source data is a table containing orders/events like so:
Let's say you care about the number of orders created monthly, and you want to set a goal for this metric.
*The example uses PostgreSQL, but the general idea should be applicable to other SQL flavors.*
## General approach
To visualize the goal number, you will need to add your number as a column alongside your metrics column
```
| report_time | total_orders | order_target |
| ----------- | ------------ | ------------- |
| 1 | 123 | 2000 |
| 2 | 234 | 2000 |
| 3 | 432 | 2000 |
| 4 | 100 | 2000 |
| 5 | 156 | 2000 |
```
### If your goal is static
If your goal is just a static number, then you can simply add it as a custom dimension to your model.
The result will look like this:
:::warning
To avoid the wrong number happening when using date-drill, the static goal-line should be used with Min/Max Aggregation type in Visualization.
:::
### If your goal is dynamic
Sometimes, the goal of your metrics are not static and involve multiple tables. Imagine that you have a table that contains the monthly sales goal:
If you’re well versed in SQL, naturally you will write a query to calculate your metric and join it with the goal table:
```sql
with aggr as (
select
date_trunc('month', created_at::date) as report_time,
count(*) as total_orders
from ecommerce.orders
group by 1
) -- to pre-aggregate the orders by month
select
aggr.*,
tgt.order_target
from aggr
left join ecommerce.sales_target tgt on aggr.report_time = tgt.report_time
```
Result:
You can then save this as a Query Model.
## Visualization
After transforming the data, you will need to create a Dataset to visualize it.
Since we used the Transformation to pre-aggregate the metrics, the metric and the goal will be available as **Dimension fields** in the Holistics Transform/SQL model.
The sections below will demonstrate how we visualize these fields in **Metric Sheet, Combination Chart, and Metric KPI** visualizations.
### Metric Sheet
If your desired outcome looks something like the table below:
```
| Metrics | Aug 2021 | Sep 2021 | Oct 2021 | Nov 2021 |
| ------------ | ---------- | ---------- | ---------- | ---------- |
| Order Target | 2000 | 2000 | 2000 | 2000 |
| Total Orders | 2288 | 2431 | 2949 | 1463 |
```
Holistics' Metrics Sheet visualization will do the trick. This is the type of chart that brings all of your key metrics into a single spreadsheet, giving you a bird's-eye view of your business. Please refer to the [Metric Sheets doc](/docs/charts/metric-sheets) for more information.
Choose Metric Sheet in the Visualization Settings and drag in the **Target** and **Actual Order Quantity** field.
The result will look like this:
### Combination Chart
Use Combination Chart when you want to compare your goal metric to other metrics over time.
Similar to Metric Sheet, at Visualization Settings simply choose a Combination chart for visualization. Drag in Order Target and Total Orders in **Y-Axis** for comparison**.**
Result:
### Metric KPI
If you just want to display the goal metric as a single value, Metric KPI is what you need. Drag in your Total Orders dimension in the Value and the Order Target dimension in the **Comparison Value.**
The result will look something like this:
Metric KPI also has different styling options for your preference. Please refer to the [Metric KPI docs](/docs/charts/metric-kpi#general-styling) for more information.
Result:
---
## Spinning Up Google BigQuery For Data Warehousing
:::info
Holistics requires you to [have a SQL database](/docs/connect/dont-have-sql-database.md) to host your data. If you don't currently have one, this guide shows you how to set up one with Google BigQuery.
:::
With cloud services today like Google Cloud Platform (GCP) and Holistics, setting up a data warehouse is insanely simple. In this video,
we’ll show you how to set up a Google BigQuery data warehouse **in 3 minutes**, and connect it to Holistics. BigQuery Stores the **first 10 GB and processes 1 TB of queried data per month for free**, which is pretty excellent for a free tier, so do make use of this.
VIDEO
First, setup and login to your [Google Cloud Console](https://console.cloud.google.com). Select or create a project, navigate to BigQuery in your side menu, and create a new dataset. A dataset functions like a "folder" for your data tables that you want to upload.
Under the *IAM & Admin* menu, create a new service account with BigQuery access, to generate a JSON key for connecting BigQuery to Holistics, to manage our data.
Save this JSON file for later. Remember to give this account sufficient BigQuery role privileges, such as *BigQuery Admin* permissions.
Now, to add a new data source to Holistics, just select BigQuery from the dropdown menu, copy your Google Project ID value from your Google console, paste the JSON key in, then test and save your BigQuery data source. That's it! You've setup a data warehouse!
Now you can begin moving data into BigQuery for your analytics. Use our **Data Import** features, to schedule the automatic import of data from other data sources and databases into BigQuery, to begin building your reports and dashboards.
For more information on how to start using Holistics, please have a look at our [supporting documentation](https://docs.holistics.io/).
If you have other questions you need help with, please feel free to contact us directly [here](https://www.holistics.io/contact-us/). See you on Holistics!
---
## Spinning up SQL Database with Google Cloud SQL
:::info
Holistics requires you to [have a SQL database](/docs/connect/dont-have-sql-database.md) to host your data. If you don't currently have one, this guide shows you how to set up one with PostgreSQL on Google CloudSQL.
:::
One challenge many people face when trying to go digital and get more out of their data,
is the problem of setting up a database online in the cloud. In this video demonstration,
we’ll show you how to set up a PostgreSQL database on the Google Cloud platform *in minutes*, and connect it to Holistics.
VIDEO
First, login to your Google Cloud Console, and create a PostgreSQL instance in Google Cloud SQL.
While configuring the settings of your Google Cloud instance, remember to whitelist the Holistics IP addresses by adding these to the list of authorized networks to be able to connect
your PostgreSQL database to Holistics.
Wait for a few minutes and once your instance is ready, you will then need to create a user account with a user name and password of your choice, which are details you will need in order to connect
this data source to Holistics. You will also need to create a database inside this instance.
Once you have completed all the steps above, you are ready to connect your PostgreSQL database to Holistics. If you have provided the details correctly, you will see a message indicating that you have connected successfully, when you click on the "Test Connection" button.
You can now connect this data source to Holistics.
The rest of the guide video will show you how to load a table of data from Google Spreadsheets into your PostgreSQL database using the Data Imports feature on Holistics, as well as how to build a simple chart in Holistics using the data that was imported.
VIDEO
If you'd like to setup a MySQL database instead, the steps are very similar. You can follow this video guide instead.
For more information on how to start using Holistics, please have a look at our supporting documentation on [Getting Started](/docs/intro.md).
---
## Style a custom chart
This guide shows how to style a custom chart with Vega-Lite's [`config`](https://vega.github.io/vega-lite/docs/config.html): how `config` works and how to match the look of Holistics built-in charts.
## The `config` property
`config` sits at the root of your template, alongside `data`, `mark`, and `encoding`. Anything you set here becomes the default for the whole chart. A value set on a specific `mark` or `encoding` channel still overrides the `config` default.
```aml
template: @vgl {
"data": { "values": @{values} },
"mark": "bar",
"encoding": { ... },
// chart-wide styling defaults
"config": {
"view": { "stroke": null } // removes the default border around the plot area
}
};;
```
Common things to set here are the font, the axis and gridline styling, the legend, and the color range. See the [Vega-Lite config reference](https://vega.github.io/vega-lite/docs/config.html) for the full list.
## Reuse a ready-made theme
If you want a polished look quickly, Vega-Lite ships several themes you can copy wholesale into `config`, then adjust to taste.
1. Open your chart's example in the [Vega Online Editor](https://vega.github.io/editor/).
2. Go to the **CONFIG** tab, pick a theme, and copy the config it generates.
3. Paste it into your template's root-level `config`, then tweak the colors and fonts to taste.
## Match the look of built-in charts
The Holistics [Custom Chart Library](https://github.com/holistics/custom-chart-library) styles its charts with a consistent `config`: muted labels, subtle dashed gridlines, and a clean plot area. Drop this block into your template's root level to give a custom chart the same look. It is taken from the library's [bar chart](https://github.com/holistics/custom-chart-library/blob/main/bar_chart/barchart.vgl.aml).
```aml
"config": {
"font": "Inter",
"view": {
"stroke": "transparent"
},
"axis": {
"title": null,
"labelFontSize": 11,
"labelFontWeight": 500,
"labelColor": "#858B9E",
"labelPadding": 10,
"gridColor": "#F4F6F8",
"gridDash": [8, 3]
},
"axisX": {
"tickSize": 7.5,
"tickColor": "#bec1cb",
"domainColor": "#bec1cb",
"domainWidth": 1
},
"axisY": {
"ticks": false,
"domain": false
}
}
```
Built-in charts also format axis labels and tooltips using each field's own format settings (currency, decimals, date format). To match that, apply `holisticsFormat` to your encoding channels:
```aml
"encoding": {
"x": {
"field": @{fields.dimension.name},
"axis": {
"format": @{fields.dimension.format},
"formatType": "holisticsFormat"
}
},
"y": {
"field": @{fields.measure.name},
"type": "quantitative",
"axis": {
"format": @{fields.measure.format},
"formatType": "holisticsFormat"
}
}
}
```
Together, the shared `config` and the field formats make a custom chart read like the built-in charts around it. See [`holisticsFormat`](/reference/aml/custom-chart#holisticsformat) for more.
## Next steps
- [Make a custom chart interactive](/guides/create-interactive-custom-charts) so users can click and drag to filter.
- See every styling property in the [AML Custom Chart reference](/reference/aml/custom-chart).
---
## Import Data from Other Sources into Your SQL Database
VIDEO
Holistics Third-party Data Models lets you unify and model your data from different sources, using SQL-based data models to generate analytics.
Say you're running a marketplace company, and you'd like to investigate your merchant product listings. The problem: your merchant names are in a spreadsheet, your merchant data is in MongoDB, and your product listings are stored in an SQL database.
In the past, you would have to find a way to first load and combine these three data sources within your data warehouse, before you begin creating reports. With Holistics, however, you can create data models directly from external data sources.
**Unify and model your various data sources using SQL-based data models**: Pull data from third-party data sources into your SQL database, to build easily maintainable and scalable SQL data assets. Setup modeling, transformations and reporting, in a single data modeling flow.
**Build once, and reuse repeatedly**: Skip the tedious work of creating a new pipeline and testing the data and writing code to combine everything in multiple tools. Leverage the full power of SQL modeling to manage your data and various sources, all in a single platform!
Please have a look at our supporting documentation on Third-party Data Models for additional details, or reach out to our team for more information.
---
## Transforming data
After you import data from your sources, data is still in "raw" state and needs to be transformed to be usable in reports and explorations. For example:
* You imported data from MongoDB, and the data you need is still in a nested field
* You imported data from two financial platforms of your company, and need to combine them to have a full view of your customer activities.
This guide will walk you through the steps to transform your data with [Holistics's Query Model](/docs/query-models).
## What is a Query Model?
A Query Model is simply a `SELECT` statement that performs data transformation, coupled with mechanisms to add metadata and schedule your transformation jobs.
At the moment, only **Analysts** and **Admins** can create **Query Models**.
## How to transform data?
To begin, please follow these steps:
1. Go to **Data Modeling** page and navigate to a folder that you want to place your model in.
2. Click the **(+) button** next to the folder's name on the left panel, or click **Create** on the top right corner of the screen.
3. Select **Add Data Model from → Data Transform,** and the SQL editor will appear. From here you can start writing your transformation SQL.
4. After finishing your SQL, **Run & Validate** to preview your transformed data, then lick **Save** to finish the process.
For example, I'm writing a model to combine **order_items**, **orders** and **products** models into a master model so I can calculate metrics like GMV, NMV... later:
The syntax depends on your database's SQL flavor (in the example above, we used a PostgreSQL database). Therefore, you do not need to learn a totally new query language, aside from some additional Holistics syntax:
* **Model reference:** `{{ #model_name as alias }}` instead of select directly from tables. This is required so that Holistics's engine can recognize the other models that your transformation depends on.
* **Field reference:** `{{ #alias.field_name }}` or `{{ #model_name.field_name }}` . This is to ensure Holistics's engine to pickup only necessary fields, and to make use of any Calculated Fields/Measures you pre-created on any of the referenced models.
## Schedule your transformation
You can also set schedules to periodically write your transformation result to the database to improve later queries' performance by following these steps:
1. Toggle on the Storage Setting at the top right corner
2. Configure settings for the transformation, like destination, run frequency or transformation mode on the Storage Settings modal.
For more detailed information, please refer to our docs: [Storage Settings](/docs/persistence)
## Reuse Calculated Fields / Measures in transformations
In [Calculated Fields and Measures guide](/tutorials/add-custom-fields), we have already known that they are virtual fields created on a model to combine existing fields/measures. These fields and measures are normally used in the drag-and-drop exploration interface, but you can also use them to make your analytics code cleaner.
For example, in model **order_item_transform** and **ecommerce_orders**,I have created a few measures like **orders_count** and **delivered_orders_count** as follows:
```
//orders_count
count({`{#THIS.id}`})
// delivered_orders_count
count(case when {`{#THIS.status}`} = 'delivered' then {`{#THIS.id}`} else null end)
```
Now I want to write a Query Model to aggregate some statistics of each user. Normally I will need to write the whole `CASE ... WHEN ...` clause again, but here I only need to refer to the easure's name:
Calculated Fields can also be used in the same way:
Holistics's SQL generation engine will parse the field and measure reference, and generate the appropriate query to be run against your database.
**Notes:**
* Measures and Calculated Fields are simply SQL snippets, so when you use them in queries you have to give them a name (alias), or the resulted column will be named by your database's engine.
* Measures are still aggregations (`SUM`, `COUNT`, `MAX`, `MIN`...) so you still need the `GROUP BY` clause at the end of your query.
Calculated Fields/Measures reference in your query is a very powerful tool, especially when you want to reuse your logic and improve your analytics code readability. In this article, we only briefly see what it can do. For more use cases, you can check the following articles:
* **How to model your MongoDB data**
* **How to work with Pipedrive custom fields**
* **Event modeling with Snowplow**
See you in the next guide!
---
## Control who can view or edit Dashboards
In a large organization, the amount of dashboards and reports can be overwhelming, and information security is a big concern. Holistics provides some simple ways to ensure users only have access to their relevant information.
## Share a dashboard
With Holistics's **User Access** function, you can share reports/dashboards/folders to **specific users or groups.**
Here we're going to share the **Ecommerce Dashboard** to a particular user or a user group. Just click on **Share > Share by Users/Groups:**
In contrast, if you want to publish my dashboard to all users, just simply toggle on Publish mode:
For more information, please refer to our docs: [Permission System](/docs/admin/permission-system#dashboard-level-permission).
## Lock a dashboard
Normally there may be several users playing the role Analysts in your organization, and you do not want everyone to make adjustments to your dashboards. In this case, **locking your dashboard** should be a good choice.
Simply click on the **More** menu on the top right corner of your dashboard, then **Lock:**
When a dashboard is locked, you will see an icon indicating that only the owner or admins can modify it.
---
## Using a Date Dimension model
Date Dimension is a popular data modeling practice when working with date/calendar data. Date Dimension is a table that has one record per each day. Depends on the period used in the business you can define start and end of the date dimension.
Some of the common scenarios that using Date Dimension helps with:
* Avoid scenario where you don't have continuous date values in your data
* Help compare metrics from different tables by time dimension
## Creating Date Dimension Model in Holistics
In Holistics, you can create and use Date Dimension through 3 steps:
1. Create a date model
2. Add Relationship between the date model with the relevant models' date fields.
3. Include the date model in the interested datasets
### 1. Create Date Model
Go to the Development workspace and create a SQL model name `dim_dates` (you can name it whatever you want):
```sql
SELECT
*
FROM generate_series (
'2017-01-01'::date,
'2025-01-01'::date,
'1 day'::interval
) date_d
```
_The above code is in PostgreSQL, but the general logic still applies to other database types_
You may add more columns to extract other information from the date and create a full date reference table (for example, weekday, week number, month name etc...), but we will go with one column for simplicity's sake.
Note that this step only needs to be done once. Skip if you already have a date dimension model.
### 2. Link Date Model with Relevant Models
Go to `dim_dates` model and create a 1-many relationship between the `date_d` field with relevant models.
* You can create multiple relationships
* Make sure `date_d` is on the one-end of the one-many relationship.
The final result will look something like the image below
### 3. Include Date Model in Relevant Datasets
Edit your existing dataset and include `dim_dates` model.
If your `dim_dates` series ends on a future date instead of `current_date`, add a filter so reports only show rows up to today.
Then, in the Explore UI, drag in the Date field from the `dim_dates` model to report the numbers along with it:
When adding the exploration to a dashboard, remember to filter on the Date dimension instead of the **Order Date** or **Sign Up Date:**
## Use Case: Compare metrics from different tables by date
Let's go through a use case in Holistics that utilizes Date Dimension effectively.
Suppose you have an Ecommerce dataset with `orders` and `users` model having a relationship like so:
You want to place **number of signups daily** and **number of orders daily** in the same report:
Using the dataset above, you may drag in two measures **Signups Count**, **Orders Count**, and for date dimension you may use **Sign Up Date:**
Unfortunately, this is not the correct result. Because of the relationship `orders.user_id - users.id` in the dataset, using **Sign Up Date** here will give you **number of orders created by users who registered at a certain date** (Which is not what you want)
```sql
select
u.sign_up_date
, count(distinct u.id) as signups_count -- Correct
, count(o.id) as orders_count -- Incorrect
from orders o
left join users u on o.user_id = u.id
```
Similarly, if you use **Order Date,** you will get the correct orders count, but for users you would get **number of users who placed orders at a certain date** (Also not what you want)
```sql
select
o.order_date
, count(distinct u.id) as signups_count -- Incorrect
, count(o.id) as orders_count -- Correct
from orders o
left join users u on o.user_id = u.id
```
The correct reporting logic would be:
- First, aggregate number of signups to daily level
- Next, aggregate number of orders to daily level
- Finally, combine the two aggregations
- What we want, in SQL term, is something like this:
```sql
with aggr_orders as (
select order_date, count(id) as orders_count from orders group by 1
)
, aggr_users as (
select sign_up_date, count(id) as users_count from users group by 1
)
-- combine
select
coalesce(ao.order_date, au.sign_up_date) as report_date
, ao.orders_count
, au.users_count
from aggr_orders ao
full join aggr_users au on ao.order_date = au.sign_up_date
```
Use the date modeling approach above, we can replicate the effect.
---
## AML Canvas Layout
`CanvasLayout` is the value for the `view` parameter in a `Dashboard`. It defines the canvas dimensions and positions each block using pixel coordinates.
```aml
Dashboard my_dashboard {
// ... blocks ...
view: CanvasLayout {
label: 'View 1'
width: 1080
height: 620
block v1 { position: pos(0, 0, 540, 300) }
block v2 { position: pos(540, 0, 540, 300) }
}
}
```
## Parameters
Parameter | Description
--- | ---
`label` | Display name for this view. Shown as a tab label when multiple views exist.
`width` | Canvas width in pixels.
`height` | Canvas height in pixels.
`margin` | Optional margin around the canvas edge in pixels.
`grid_size` | Optional grid snap size in pixels for alignment.
## Block positioning
Each block declared in the dashboard must be positioned inside `CanvasLayout` using `pos(left, top, width, height)` (all values in pixels, measured from the top-left corner of the canvas).
```aml
block v1 { position: pos(left, top, width, height) }
```
Parameter | Description
--- | ---
`left` | Horizontal offset from the left edge of the canvas.
`top` | Vertical offset from the top edge of the canvas.
`width` | Block width.
`height` | Block height.
## Mobile layout
`CanvasLayout` supports an optional `mobile` block that controls how the dashboard renders on small screens. Mobile view stacks blocks in a single column.
Parameter | Description
--- | ---
`mode` | `'auto'` (default) mirrors the desktop order, `'manual'` lets you specify a custom order and sizes, `'none'` disables mobile adjustments.
In `manual` mode, list the blocks in the order they should appear on mobile. Each block entry can override `height` or `aspect_ratio`.
```aml
view: CanvasLayout {
width: 1080
height: 800
block f1 { position: pos(0, 0, 300, 60) }
block v1 { position: pos(0, 70, 540, 300) }
block v2 { position: pos(540, 70, 540, 300) }
mobile: {
mode: 'manual'
block f1 // filter first
block v1 {
aspect_ratio: '16 / 9'
}
block v2 {
height: 250
}
}
}
```
In `auto` mode (the default), Holistics arranges blocks left-to-right, top-to-bottom based on their desktop positions. No extra configuration is needed.
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax including interactions and settings
- [AML Tab Layout](/reference/aml/tab-layout): tabbed canvas dashboard
- [AML HTML Layout](/reference/aml/html-layout): HTML and CSS layout for custom compositions, slides, and paginated reports
- [Mobile Responsiveness](/docs/canvas-dashboard/mobile-responsive): guide with visual examples of mobile modes
---
## AML Constant
## Introduction
An AML constant allows you to specify a value that can be reused throughout a project.
- Once a constant is declared, user can not change its value through reassignment (it is immutable)
- Cannot declare same name constant in the same scope (i.e. AML modules and functions)
## Syntax
There are two equivalent ways to declare a new constant. You can declare the constant using the `const` keyword without explicit [type](/reference/aml/types):
```aml
const =
const a = 'hello'
```
or you can explicitly declare the constant’s type
```aml
=
Int e = 3
```
In the first case, the type of const variable is **automatically inferred** from its value.
## Example usages
### Basic types
```aml
const a = 'hello' // a's type is String
const b = 2 // b's type is Number
// Explicit type declaration
String c = 'world'
Number d = 2.5 + b // 4.5
Int e = 3
// Use the constant with string interpolation
const signup_threshold = 0.6 // signup_threshold's type is Number
Dataset my_dataset {
metric count {
definition: @aql count(*)
// highlight-next-line
| where users.signup_threshold < ${signup_threshold}
;;
}
}
```
:::tip
[String interpolation](/reference/aml/string-interpolation) is a common way to reuse basic constant types.
:::
### Lists and dictionaries
- Declare with `const` keyword
```aml
// AML list
const models = ['users', 'countries', 'orders']
// access to list's elements
models(0) // 'users'
models(1) // 'countries'
models(2) // 'orders'
// AML dictionary
const modelDict = {
users: 'users'
countries: 'countries'
orders: 'orders'
}
// access to dictionary's elements
modelDict('users') // 'users'
modelDict('countries') // 'countries'
modelDict('orders') // 'countries'
```
- Explicit type
```aml
// AML list
Type ListString = List[String]
ListString models = ['users', 'countries', 'orders']
// access to list's elements
models(0) // 'users'
models(1) // 'countries'
models(2) // 'orders'
// AML dictionary
Type StringDict = Dict[String, String]
StringDict modelDict = {
users: 'users'
countries: 'countries'
orders: 'orders'
}
```
### Object types
```aml
const my_filter = FilterBlock { // my_filter's type is FilterBlock
type: 'field'
label: 'User role'
source: FieldFilterSource {
dataset: 'tenant_user'
field: r(public_users.role)
}
}
const my_dashboard_1 = Dashboard {
...
block f1: my_filter // reuse my_filter in multiple dashboards
...
}
Dashboard my_dashboard_2 {
...
block f2: my_filter // reuse my_filter in multiple dashboards
...
}
Model extended_users = users.extend({
label: 'Extended users'
measure count {
label: 'count'
type: 'number'
definition: @aql sum(extended_users.id) ;;
}
}
// equivalent to
const extended_users2 = users.extend({
label: 'Extended users'
measure count {
label: 'count'
type: 'number'
definition: @aql sum(extended_users.id) ;;
}
}
```
## See also
- [Define & reuse global SQL definitions](/as-code/aml/use-cases/const-reuse-sql-definitions)
- [Build a Dashboard with Multiple Similar Charts](/docs/canvas-dashboard/build-similar-dashboards)
---
## AML Dashboard Blocks
A dashboard is built from blocks. Each block is declared with `block : { ... }` inside a `Dashboard` and occupies a position in the canvas layout.
There are four block types:
- **[TextBlock](text-block)**: displays Markdown or HTML content
- **[VizBlock](viz-block)**: wraps a chart, table, or KPI visualization
- **[FilterBlock](filter-block)**: adds a user-controlled filter
- **[DateDrillBlock](date-drill-block)**: lets viewers switch time granularity across charts
```aml
Dashboard my_dashboard {
block t1: TextBlock { ... }
block v1: VizBlock { ... }
block f1: FilterBlock { ... }
block d1: DateDrillBlock { ... }
}
```
Each block's position in the dashboard is defined in the `view` section. See [AML Dashboard](/reference/aml/dashboard) for the full syntax including layout, interactions, and settings.
---
## AML Dashboard Interactions
The `interactions` list in a dashboard wires control blocks to visualization blocks. When a viewer interacts with a control (e.g. a `DateDrillBlock`), Holistics applies the change to all mapped visualization blocks.
```aml
Dashboard my_dashboard {
// ... blocks ...
interactions: [
DateDrillInteraction { ... }
]
}
```
Currently, `DateDrillInteraction` is the interaction type configurable in AML. Other interactions (cross-filtering, drill-through, drill-down) are configured through the dashboard UI, not AML.
## DateDrillInteraction
`DateDrillInteraction` connects a `DateDrillBlock` to one or more visualization blocks, mapping a date field in each target block to the viewer-selected time granularity.
Parameter | Description
--- | ---
`from` | Name of the `DateDrillBlock` that drives this interaction.
`to` | List of `CustomMapping` objects, one per target visualization block.
### CustomMapping
Each `CustomMapping` entry specifies a target block and which date field in that block should be transformed.
Parameter | Description
--- | ---
`block` | Name of the target `VizBlock` (as a string).
`field` | Reference to the date field to transform, using `r(model.field)` syntax.
## Example
```aml
Dashboard sales {
block d1: DateDrillBlock {
label: 'Drill by'
default: 'month'
}
block v1: VizBlock {
label: 'Revenue Over Time'
viz: LineChart { dataset: ecommerce }
}
block v2: VizBlock {
label: 'Orders Over Time'
viz: BarChart { dataset: ecommerce }
}
interactions: [
DateDrillInteraction {
from: 'd1'
to: [
CustomMapping { block: 'v1', field: r(orders.created_at) },
CustomMapping { block: 'v2', field: r(orders.created_at) }
]
}
]
}
```
## Other interaction types
These interactions are configured through the dashboard UI rather than AML code:
- **Cross-filtering**: clicking a data point in one visualization filters others. See [Cross-filtering](/docs/cross-filtering).
- **Drill-through**: clicking a data point navigates to another dashboard. See [Drill-through](/docs/interactions/drill-through).
- **Drill-down / Break-down**: viewers add dimensions on the fly. See [Drill-down & break-down](/docs/interactions/drill-down).
## See also
- [DateDrillBlock](/reference/aml/date-drill-block): the control block for this interaction
- [Date drills](/docs/interactions/date-drills): concept guide and UI setup
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax
---
## AML Dashboard
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Canvas Dashboard](/docs/dashboards/)
:::
:::info
Please note that dashboard files have the extension `.page.aml`.
Its full name is `dashboard_name.page.aml`.
:::
## Dashboard Components
There are five components that form a dashboard:
- Metadata
- Collection of blocks
- Block interactions
- Settings
- Views
## Parameter Definition
Tips: Hover over any text to see supported parameters in the tooltip.

Parameter name | Description
-------------- | ------------
dashboard | Specify the dashboard’s unique name in the workspace
title | Specify the dashboard’s display name in the Reporting layer
description | Specify dashboard description
theme | Apply a theme to the dashboard. Accepts a pre-built theme name, a custom `PageTheme` object, or an extended theme. See [AML Theme and Colors](/reference/aml/theme)
block | Define blocks in the dashboard
interactions | Define how blocks interact with each other in the dashboard
view | Define dashboard layout. Accepts `CanvasLayout`, `TabLayout`, or `HTMLLayout`.
settings | Define dashboard settings
## Dashboard syntax example
```aml title="my_dashboard.page.aml"
Dashboard myDashboard {
// metadata
title: 'My Dashboard'
description: ''''''
theme: my_custom_theme // or: PageTheme { ... } / H.themes.name
// collection of blocks
block t1: TextBlock {
content: @md # Hello World!;;
}
block v1: VizBlock {
label: 'GMV Over Time'
viz: CombinationChart {
dataset: demo_ecommerce
...
settings {
row_limit: 5000
legend_label: 'top'
}
}
}
block v2: VizBlock {
label: 'Order Details'
viz: DataTable {
dataset: demo_ecommerce
fields: [
...
]
settings {
row_limit: 5000
}
}
}
block f1: FilterBlock {
label: 'Order Created At'
type: 'field'
source: FieldFilterSource {
dataset: demo_ecommerce
field: r(order_master.order_created_at)
}
default {
operator: 'matches'
value: 'last 2 years'
}
}
block d1: DateDrillBlock {
label: 'Drill by'
default: 'month'
}
// interactions
interactions: [
DateDrillInteraction {
from: 'd1'
to: [
CustomMapping {
block: 'v1'
field: r(order_master.order_created_at)
}
]
}
]
// settings
settings {
timezone: 'America/Los_Angeles'
cache_duration: 360
}
// view
view: CanvasLayout {
label: 'View 1'
width: 1080
height: 620
block t1 {
position: pos(30, 30, 250, 60)
}
block v1 {
position: pos(300, 30, 760, 250)
}
block v2 {
position: pos(300, 300, 760, 300)
}
block f1 {
position: pos(30, 190, 250, 80)
}
block d1 {
position: pos(30, 100, 250, 80)
}
}
}
```
**Output:**

## Theme syntax
Dashboard themes are defined using `PageTheme`, `BlockTheme`, and `VizTheme` AML objects. See [AML Theme](/reference/aml/theme) for the full parameter reference.
## See also
- [Dashboard Blocks](/reference/aml/dashboard-blocks): TextBlock, VizBlock, FilterBlock, DateDrillBlock parameter reference
- [Dashboard Interactions](/reference/aml/dashboard-interactions): DateDrillInteraction and CustomMapping reference
- [AML Canvas Layout](/reference/aml/canvas-layout): pixel-positioned view layout and block positioning reference
- [AML Tab Layout](/reference/aml/tab-layout): tabbed view layout reference
- [AML HTML Layout](/reference/aml/html-layout): HTML and CSS layout for custom compositions, slides, and paginated reports
- [Canvas Dashboards](/docs/dashboards/): concept guide and UI walkthrough
---
## Data Format
You can format AML fields to **improve readability** for your reports. This section will help you define a **Data-Format-As-Code** using AML.
Available formats include:
- [Date Format](date-format)
- [Number Format](number-format)
## High-level concept
To format an AML dimension/measure, you will need to add a `format` property to the dimension/measure definition that contains a **format pattern** to this dimension/measure.
If your pattern is invalid, Holistics will **fall back to the default format** to render your data.
```aml
Model public_accounts {
type: 'table'
label: 'Accounts'
table_name: '\"public\".\"Accounts\"'
data_source_name: "piggy_bank"
dimension balance {
label: "Balance"
type: "number"
definition: @sql {{ #SOURCE.balance }};;
hidden: false
format: "#,###0.00,,\"M\""
}
}
```
---
## AML Dataset Fields
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dimension in Dataset](/docs/dimensions-in-datasets)
- [Metrics introduction](/as-code/aql/learn/what-aql-is-for)
- [Cross-model Reference](/as-code/aql/learn/cross-model)
:::
## Introduction
To enable [cross-model calculations](/as-code/aql/learn/cross-model) and have a logical structure for the analytics codebase, we can also create **Dimensions** and **Metrics** within a Dataset:
- **Dataset Dimension** is analogous to Model Dimension
- **Dataset Metric** is analogous to Model Measure. However, Dataset Metric has more functionalities than Model Measure.
However, their definition and usage are different from those of the Model's counterparts.
## Dimension
In Dataset Dimension, you can combine dimensions from **one or more models**. In general, Dataset Dimension's declaration is similar to that of Model Dimension, except for an extra `model` parameter that specifies the **source model** of the cross-model reference.
### Parameter Definition
Parameter name | Required | Description
-------------- | -------- | ------------
model | Yes | The **source model** of the cross-model reference. The dimension also appears under this model in the exploration UI.
label | Yes | How the dimension appears in the exploration UI.
type | Yes | Data type of the dimension. Possible values: `'text'`, `'number'`, `'date'`, `'datetime'`, `'truefalse'`, `'json'`, `'unknown'`.
definition | No | How the dimension is calculated, using `@sql` or [`@aql`](/as-code/aql/) syntax.
description | No | Describes the semantic of the dimension.
hidden | No | Default `false`. Hides the dimension from the Exploration interface of Dataset and Report. This is **not a Security Feature** ([reason](/reference/aml/field#should-i-use-the-hidden-property-in-dimensionmeasure-for-data-restriction-purposes)).
format | No | Display format for the dimension's value. See [Number Format](/reference/aml/number-format) and [Date Format](/reference/aml/date-format).
### Example of Dataset Dimension Definition
```aml
Dataset e_commerce {
(...)
dimension full_name {
model: users
type: 'text'
label: 'Full name'
definition: @aql concat(users.first_name, ' ', users.last_name);;
}
dimension age_by_year {
model: users
type: 'text'
label: 'Full name'
definition: @aql date_diff('day', users.birth_date, @now) / 365;;
}
}
```
## Metric
Datasets can also expose **Metrics**: aggregations defined in AQL that can span multiple models.
Metrics defined inline inside a `Dataset { ... }` block use the same `Metric` type as standalone, reusable metric definitions. For the full parameter list, examples, and how to organize them, see **[AML Metric](/reference/aml/metric)**.
## Notes
### Dataset Dimension vs. Model Dimension
Even though it is possible to define dimensions in both Dataset and Model, we recommend the following:
- If the dimension transforms **fields within the same model**, it should be a **Model Dimension**.
- If the dimension transforms **fields across multiple models**, it should be a **Dataset Dimension**.
### Dataset Metric vs. Model Measure
Similarly, even though it is possible to define a certain aggregation in both the Dataset and Model levels, we recommend the following:
- If the aggregation transforms **fields within the same model**, it should be a **Model Measure**.
- If the aggregation transforms **fields across multiple models**, it should be a **Dataset Metric**
As mentioned earlier, although Dataset Metric is analogous to Model Measure, it is actually much more powerful. For a more extensive discussion of metrics, see [**What is a metric?**](/as-code/aql/learn/what-aql-is-for) and [**Create metrics in datasets**](/docs/metrics-in-datasets).
---
## AML Dataset
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dataset](/docs/datasets)
:::
## Introduction
In Holistics, datasets are defined in `.dataset.aml` files. The full dataset file name has the form `dataset_name.dataset.aml`. The dataset definition typically contains the following information:
- Dataset metadata: dataset labels, descriptions, owners
- Data Source reference: users' exploration activities will use this source
- Data models included
- Relationships
- Metrics
- Dataset view definition
The following section will list all current dataset parameters.
## Parameter definition
Parameter name | Description
-------------- | ------------
dataset | Specify the dataset's unique name in the workspace
label | Specifies how the dataset will appear in the Ready-to-explore Dataset
description | Add dataset description
owner | Define who should be in charge of managing the current dataset
data_source_name | Specify the database that Holistics will execute the generated query against (in the dataset)
[relationships](/reference/aml/relationship) | Specify relationship and their configuration among added models
[models](/reference/aml/model) | Specify which models will be used in the dataset
[view](/docs/datasets/custom-views) | Define how models and fields are displayed in Preview / Dataset Exploration
[dimension](/reference/aml/dataset-field#dimension) | Define cross-model dimensions in the dataset |
[metric](/reference/aml/dataset-field#metric) | Define metrics to be used in the dataset
context | Configure analysis interactions for the dataset, including [breakdown dimension lists](/docs/interactions/drill-down#customize-at-dataset-level) and [underlying data views](/docs/interactions/view-underlying-data#in-dataset)
settings | Configure dataset-level settings, such as [enabling or disabling analysis interactions](/docs/interactions/drill-down#disable-the-feature)
[pre_aggregates](/docs/aggregate-awareness/persistence) | Define pre-aggregated tables for [Aggregate Awareness](/docs/aggregate-awareness), with built-in or external persistence
[permission](/docs/access-control/row-level-permission-as-code) | Define row-level permission rules to filter data based on user attributes. *(Coming soon)*
## Dataset syntax examples
### Core: metadata, models, and relationships
Every dataset starts with metadata and declares which models and relationships to include:
```aml
Dataset ecommerce {
label: '[Demo] Ecommerce'
description: 'Demo dataset for E-commerce use cases'
owner: 'demo@holistics.io'
data_source_name: 'demodb'
models: [
ecommerce_orders,
ecommerce_order_items,
ecommerce_users,
ecommerce_products,
ecommerce_categories
]
relationships: [
relationship(ecommerce_orders.user_id > ecommerce_users.id, true),
relationship(ecommerce_order_items.order_id > ecommerce_orders.id, true),
relationship(ecommerce_order_items.product_id > ecommerce_products.id, true),
relationship(ecommerce_products.category_id > ecommerce_categories.id, true)
]
}
```
### Dimensions and metrics
You can define cross-model dimensions and metrics directly in the dataset using [AQL](/as-code/aql/) expressions. For full details, see [Dataset Fields](/reference/aml/dataset-field).
```aml
Dataset ecommerce {
// ... models and relationships omitted
// Cross-model dimension
dimension full_name {
model: ecommerce_users
type: 'text'
label: 'Full Name'
definition: @aql concat(ecommerce_users.first_name, ' ', ecommerce_users.last_name);;
}
// Simple aggregation
metric count_orders {
label: 'Count Orders'
type: 'number'
definition: @aql count(ecommerce_orders.id) ;;
}
// Cross-model aggregation
metric sum_order_value {
label: 'Sum Order Values'
type: 'number'
definition: @aql sum(ecommerce_order_items, ecommerce_order_items.quantity * ecommerce_products.price) ;;
}
// Derived metric referencing other metrics
metric average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql sum_order_value / count_orders;;
}
}
```
### View, context, and settings
Use `view` to organize how models and fields appear in the exploration UI. Use `context` to configure [drill-down and break-down](/docs/interactions/drill-down) dimension lists and [underlying data views](/docs/interactions/view-underlying-data). Use `settings` to enable or disable analysis interactions.
```aml
Dataset ecommerce {
// ... models, relationships, dimensions, metrics omitted
// Organize the exploration UI
view {
model ecommerce_orders { }
model ecommerce_users { }
group relevant_models {
model ecommerce_products { }
model ecommerce_categories { }
}
group business_metrics {
metric sum_order_value
metric average_order_value
}
}
// Configure analysis interactions
context {
analysis {
// Breakdown dimension groups for drill-down
breakdown {
group location {
label: 'Locations'
fields: [
r(ecommerce_users.country),
r(ecommerce_users.city),
]
}
group product {
label: 'Products'
fields: [
r(ecommerce_products.category),
r(ecommerce_products.name),
]
}
}
// Underlying data views
underlying_data {
metric count_orders {
view list_of_orders {
label: 'List of Orders'
fields: [
r(ecommerce_orders.id),
r(ecommerce_orders.created_date),
r(ecommerce_orders.status),
r(ecommerce_users.full_name),
]
}
}
}
}
}
// Dataset-level settings
settings {
analysis_interactions {
breakdown {
enabled: true
}
view_underlying_data {
enabled: true
}
}
}
// Pre-aggregated table for Aggregate Awareness
pre_aggregates: {
agg_orders: PreAggregate {
dimension created_at_day {
for: r(ecommerce_orders.created_at)
time_granularity: "day"
}
dimension status {
for: r(ecommerce_orders.status)
}
measure count_orders {
for: r(ecommerce_orders.id)
aggregation_type: 'count'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
}
// Row-level permission (coming soon)
permission regional_access {
field: r(ecommerce_orders.region)
operator: 'matches_user_attribute'
value: 'region'
}
}
```
---
## AML DateDrillBlock
DateDrillBlock lets viewers switch the time granularity of mapped visualization blocks without editing the underlying report. It is connected to visualization blocks via a `DateDrillInteraction`.
## Syntax
```aml
block : DateDrillBlock {
label: 'Control Label'
default: 'month' // optional
}
```
## Parameters
Parameter | Description
--- | ---
`label` | Display name shown on the control.
`default` | Default time granularity on load. Accepted values: `'year'`, `'quarter'`, `'month'`, `'week'`, `'day'`.
## Connecting to visualization blocks
A `DateDrillBlock` alone does nothing. It must be wired to visualization blocks using a `DateDrillInteraction` in the dashboard's `interactions` list. Each `CustomMapping` specifies which block responds and which date field gets transformed.
```aml
Dashboard sales {
block d1: DateDrillBlock {
label: 'Drill by'
default: 'month'
}
block v1: VizBlock {
label: 'Revenue Over Time'
viz: LineChart {
dataset: ecommerce
}
}
interactions: [
DateDrillInteraction {
from: 'd1'
to: [
CustomMapping {
block: 'v1'
field: r(orders.created_at)
}
]
}
]
}
```
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax
- [Date Drill Controls](/docs/dashboards/date-drill-controls): how date drill works in the UI
---
## Date Format
:::info Note
To apply **Date Format** for your dimension/measure, ensure its `type` is `date`.
:::
## Overview of Date Format
To format your date fields, add a **format** property with a string pattern representing your chosen format.
```json
dimension order_created_at_raw {
label: "Order Created At Raw"
type: "date"
format: "dd-LL-yyyy"
}
dimension order_created_at {
label: "Order Created At"
type: "date"
format: "dd LLL, yyyy"
}
```

### Day part (required)
```ruby
dd
```
- **Example:** 01, 12
### Month part (required)
```ruby
LL or LLL
```
- **Description:** You can use the numeric value, or the three-letter abbreviation for months.
- **Example:** 08, 12, Jan, Jul
### Year part (required)
```ruby
yyyy
```
- **Example:** 2012, 1990
### Separator (required)
```ruby
- or / or , or
```
- **Example:** 09/01/1990
## Supported Date Format patterns
| Date Patterns | Example |
| --- | --- |
| dd/LL/yyyy or dd-LL-yyyy | 08/02/2000 or 08-02-2000 |
| LL/dd/yyyy or LL-dd-yyyy | 12/13/2022 or 12-13-2022 |
| LLL dd yyyy | Jan 01 2022 |
| LLL dd, yyyy | Jan 01, 2022 |
| dd LLL, yyyy | 12 Jan, 2002 |
---
## AML Quick Reference
## Introduction
This page provides condensed code examples for quick reference when writing AML. For detailed explanations and parameter definitions, see the full reference pages linked in the table below.
AML Dataset
Jump to example
Full Reference
AML Model
Jump to example
Full Reference
AML Fields
Jump to example
Full Reference
AML Relationship
Jump to example
Full Reference
AML Dashboard
Jump to example
Full Reference
AML Dataset Fields
Jump to example
Full Reference
AML Persistence
Jump to example
Full Reference
AML Constant
Jump to example
Full Reference
AML Function
Jump to example
Full Reference
AML Module
Jump to example
Full Reference
AML Extend
Jump to example
Full Reference
AML Partial
Jump to example
Full Reference
AML If-else
Jump to example
Full Reference
AML String Interpolation
Jump to example
Full Reference
AML Theme
Jump to example
Full Reference
## Dataset File
```aml
Dataset raw_ecommerce {
label: 'Raw Ecommerce'
description: "This Dataset is about Ecommerce data"
owner: 'khai@holistics.io'
data_source_name: 'demodb'
models: [
users,
orders
]
relationships: [
// define relationship between orders and users is many to one
relationship(orders.user_id > users.id, true)
]
}
```
## Model File
### Table Model
```aml
Model users {
type: 'table'
label: "Users"
data_source_name: 'bigquery_dw'
table_name: 'users'
dimension id {
label: 'ID'
type: 'number'
hidden: false // optional
definition: @sql {{#SOURCE.id}};; // optional
}
dimension email {
label: 'Email'
type: 'number'
// without "definition", it automatically uses the same column name as the dimension name ('email')
}
measure user_count {
type: 'number'
label: 'Count Users'
definition: @aql count(users.id) ;;
}
}
```
### Query Model
```aml
Model location {
type: 'query'
label: 'Location'
data_source_name: 'demodb'
models: [cities, countries]
query: @sql
select {{ #ci.id }} as city_id
, {{ #ci.name }} as city_name
, {{ #co.code }} as country_code
, {{ #co.name }} as country_name
, {{ #co.continent_name }}
from {{ #cities as ci }}
left join {{ #countries as co }} on {{ #co.code }} = {{ #ci.country_code }};;
dimension city_id {
label: 'City Id'
type: 'number'
}
dimension city_name {
label: 'City Name'
type: 'text'
}
dimension country_code {
label: 'Country Code'
type: 'text'
}
dimension country_name {
label: 'Country Name'
type: 'text'
}
dimension continent_name {
label: 'Continent Name'
type: 'text'
}
}
```
### Measure
```aml
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
measure total_users {
label: 'Total Users'
type: 'number'
definition: @aql count(users.id) ;;
}
```
### Dimension
```aml
// This is a simple dimension. Holistics automatically assume there is a `created_at` column in your
// underlying table
dimension created_at {
label: 'Created At'
type: 'datetime'
}
// This is a derived dimension based on the dimension above, using AQL.
dimension created_at_year {
label: 'Created At Year'
type: 'number'
definition: @aql date_part('year', orders.created_at) ;;
}
// This is a simple dimension, with explicitly defined column name.
dimension status {
label: 'Status'
type: 'text'
// If you don't include this line, Holistics will automatically use the table's column that bear the
// same name with the dimension (i.e status).
definition: @sql {{ #SOURCE.status }};;
}
// This is a dimension which uses AQL case-when syntax.
dimension age_group {
label: 'User Age Group'
description: "'Under 18', '18 - 22', '23 - 30', '31 - 40', 'Over 40'"
type: 'text'
hidden: false
definition: @aql case(
when: users.age < 18, then: 'Under 18',
when: users.age >= 18 and users.age < 23, then: '18 - 22',
when: users.age >= 23 and users.age < 31, then: '23 - 30',
when: users.age >= 31 and users.age < 41, then: '31 - 40',
else: 'Over 40'
) ;;
}
// This is a dimension to extract month's name from the base dimension `created_at`.
dimension month_name {
label: 'Month Name'
type: 'text'
definition: @aql date_format(orders.created_at, '%m - %B') ;; // example output: '12 - December'
}
```
## Relationship File
### Full-form Relationship
```aml
// Relationship full form defined in dataset file
RelationshipConfig {
rel: Relationship {
type: 'many_to_one'
from: r(products.merchant_id)
to: r(merchants.id)
}
active: true
},
// Relationship defined in model file
Relationship relationship_name {
type: 'many_to_one'
from: r(orders.user_id)
to: r(users.id)
}
```
### Short-form Relationship
#### Many-to-One relationship
```aml
// Relationship short form defined in dataset file, relationship type > is many_to_one
relationship(ecommerce_cities.country_code > ecommerce_countries.code, true, 'one_way')
```
#### One-to-One relationship
```aml
// Relationship short form defined in dataset file, relationship type - is one_to_one
relationship(ecommerce_merchants.admin_id - ecommerce_users.id, true)
```
## Dashboard
Dashboard files use the `.page.aml` extension (e.g. `my_dashboard.page.aml`).
```aml title="my_dashboard.page.aml"
Dashboard myDashboard {
title: 'My Dashboard'
description: ''''''
theme: my_custom_theme // or inline: PageTheme { ... }
block t1: TextBlock {
content: @md # Hello World!;;
}
block v1: VizBlock {
label: 'GMV Over Time'
viz: CombinationChart {
dataset: demo_ecommerce
}
}
block f1: FilterBlock {
label: 'Order Created At'
type: 'field'
source: FieldFilterSource {
dataset: demo_ecommerce
field: r(order_master.order_created_at)
}
default {
operator: 'matches'
value: 'last 2 years'
}
}
settings {
timezone: 'America/Los_Angeles'
cache_duration: 360
}
view: CanvasLayout {
label: 'View 1'
width: 1080
height: 620
block t1 { position: pos(30, 30, 250, 60) }
block v1 { position: pos(300, 30, 760, 250) }
block f1 { position: pos(30, 100, 250, 80) }
}
}
```
## Dataset Fields
Dataset-level dimensions and metrics support cross-model calculations that span multiple models.
### Dataset Dimension
```aml
Dataset e_commerce {
// ...
dimension full_name {
model: users
type: 'text'
label: 'Full Name'
definition: @aql concat(users.first_name, ' ', users.last_name);;
}
}
```
### Dataset Metric
```aml
Dataset ecommerce {
// ...
// Simple aggregation
metric count_orders {
label: 'Count Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
// Cross-model aggregation
metric sum_order_value {
label: 'Sum Order Values'
type: 'number'
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
}
```
## Persistence
Persistence is configured inside a Model using `FullPersistence` or `IncrementalPersistence`.
### Full Persistence
```aml
Model orders {
// ...
persistence: FullPersistence {
schema: 'scratch'
view_name: 'pqm_orders' // optional
}
}
```
### Incremental Persistence
```aml
Model orders {
// ...
persistence: IncrementalPersistence {
schema: 'scratch'
incremental_column: 'updated_at'
primary_key: 'id'
}
}
```
### Schedules
Schedules are defined in a `schedules.aml` file at the root of your AML project.
```aml title="schedules.aml"
const schedules = [
// Persist a Query Model every hour
Schedule { cron: '0 * * * *', models: [orders] }
// Persist a Pre-Aggregate nightly
PreAggregateSchedule {
cron: '15 20 * * *'
object: ecommerce_dataset
pre_aggregates: ['agg_transactions']
}
]
```
## Constant
```aml
const signup_threshold = 0.6
const greeting = 'Hello'
// Explicit type
Number tax_rate = 0.08
// Used in a dataset metric via string interpolation
Dataset my_dataset {
metric active_users {
definition: @aql count(*)
| where users.signup_rate > ${signup_threshold}
;;
}
}
```
## Function
```aml
// Simple function
Func double(x: Number) {
x * 2
}
// Function that returns a VizBlock
Func revenue_chart(dataset: String) {
VizBlock {
label: 'Revenue'
viz: BarChart {
dataset: dataset
series {
field { ref: r(orders.revenue), aggregation: 'sum' }
}
}
}
}
// Call the function
revenue_chart('ecommerce')
```
## Module
Modules are directories under `modules/` in your AML project root.
```
|-- datasets/
| |-- ecommerce.dataset.aml
|-- modules/
| |-- cohort/
| | |-- retention.aml
| |-- utils/
| |-- date_helpers.aml
```
Reference objects from a submodule using dot notation:
```aml
// datasets/ecommerce.dataset.aml
Dataset ecommerce {
models: [
orders,
cohort.retention_table // model from the cohort submodule
]
}
```
Import specific objects into the current file's scope with `use`:
```aml
use cohort { retention_table }
use utils { date_helpers: dates } // alias date_helpers to dates
```
## Extend
Extend an existing object to create a new one, adding or overriding properties.
```aml
Model users {
dimension id { type: 'number' }
dimension email { type: 'text' }
dimension salary { type: 'number', hidden: false }
}
// Add a new dimension
Model users_with_activation = users.extend({
dimension activated_at { type: 'datetime' }
})
// Override a property without re-declaring everything
Model users_anonymized = users.extend({
dimension salary { hidden: true }
})
```
## Partial
A partial extracts reusable properties that can be applied to multiple objects via `.extend()`.
```aml title="timestamp_fields.aml"
PartialModel timestamp_fields {
dimension created_at {
type: 'datetime'
definition: @sql {{ #SOURCE.created_at }};;
}
dimension updated_at {
type: 'datetime'
definition: @sql {{ #SOURCE.updated_at }};;
}
}
```
```aml title="users.model.aml"
Model users_base {
type: 'table'
table_name: 'public.users'
data_source_name: 'demodb'
dimension id { type: 'number' }
}
Model users = users_base.extend(timestamp_fields)
```
Partials also work at the Dataset and Dashboard levels:
```aml
PartialDataset revenue_metrics {
metric gmv { definition: @aql sum(orders.gmv);; }
metric mrr { definition: @aql sum(subscriptions.mrr);; }
}
Dataset company = company_base.extend(revenue_metrics)
Dataset store = store_base.extend(revenue_metrics)
```
## If-else
```aml
const score = 85
const grade = if (score >= 90) {
'A'
} else if (score >= 75) {
'B'
} else {
'C'
}
// grade is 'B'
```
Commonly used inside functions to switch logic based on a parameter:
```aml
Func kpi_block(metric: 'revenue' | 'orders') {
const measure_ref = if (metric == 'revenue') {
r(orders.revenue)
} else {
r(orders.id)
}
VizBlock {
label: "KPI -- ${metric}"
viz: SingleValue {
dataset: 'ecommerce'
series {
field {
ref: measure_ref
aggregation: if (metric == 'revenue') { 'sum' } else { 'count' }
}
}
}
}
}
```
## String Interpolation
Embed variable values directly into strings using `${}`.
```aml
const env = 'production'
const schema = 'analytics'
// In a string
const table_path = '${schema}.orders' // 'analytics.orders'
// In a heredoc (SQL definition)
@sql select * from ${schema}.orders ;;
// In a function label
Func chart(title: String) {
VizBlock {
label: 'Chart -- ${title}'
}
}
// Combined with if-else
Func greet(name: 'John' | 'Alice') {
"Hello, ${if (name == 'John') { 'sir' } else { 'madam' }}!"
}
```
## Theme
Themes control the visual styling of dashboards. Define a `PageTheme` in a shared file to reuse it across multiple dashboards.
```aml title="themes.aml"
PageTheme brand_theme {
background {
bg_color: '#f5f5f5'
}
canvas {
background { bg_color: '#ffffff' }
shadow: 'md'
}
block {
border { border_radius: 8, border_color: '#e0e0e0', border_style: 'solid', border_width: 1 }
background { bg_color: '#ffffff' }
label { font_family: 'Inter', font_weight: 'semibold' }
}
viz {
table {
general { bg_color: '#ffffff', font_family: 'Inter' }
header { bg_color: '#f0f0f0', font_weight: 'bold' }
}
}
}
```
Apply a theme to a dashboard via the `theme` parameter:
```aml title="my_dashboard.page.aml"
Dashboard my_dashboard {
theme: brand_theme
// ...
}
```
Override the theme for a specific block using `BlockTheme`:
```aml
block hero: TextBlock {
theme: BlockTheme {
background { bg_color: '#1a1a2e' }
label { font_color: '#ffffff' }
}
content: @md # Welcome ;;
}
```
---
## AML Extend
## Introduction
AML Extend is a function that is applied to an analytics object to produce a new object that takes on the original properties.
Consider the following example where a User model is extended for two use cases with different requirements:
```aml
Model users {
// Details of these dimensions are omitted for brevity
dimension id {}
dimension email {}
dimension signed_up_at {}
dimension first_logged_in {}
}
// Extend Users model and add a new dimension `activated_at`
// highlight-next-line
Model activatedUsers = users.extend({
// Details of this dimension are omitted for brevity
dimension activated_at {},
})
// Extend Users model and hide the dimension `email` from end users
// highlight-next-line
Model anonymizedUsers = users.extend({
dimension email { hidden: true }
})
```
Without AML Extend, you would have to duplicate the model code multiple times, which is error-prone and requires careful maintenance.
## Syntax
```aml
= .extend({
// properties to add or override
})
```
## Reuse extended logic with AML Partials
When you want to **reuse the extended logic**, you can use **[AML Partial](/reference/aml/partial)**, which represents a partial AML object. For example, you can create a `PartialDataset` that contains a bunch of metrics, then reuse them in other datasets, like below:
```tsx
// Define a Partial Type of Dataset which contains a group of metrics
PartialDataset revenue_metrics {
metric gmv { ... }
metric mrr { ... }
metric arr { ... }
}
// In company.dataset.aml
Dataset company_with_revenue = company.extend(revenue_metrics)
// In store.dataset.aml
Dataset store_with_revenue = store.extend(revenue_metrics)
```
AML Partials ensure that the extended object structure matches the type of the target object, thus improving correctness.
Many Holistics' built-in types support AML Partial: `PartialModel`, `PartialTableModel`, `PartialQueryModel`, `PartialDataset`, `PartialDashboard`, `PartialVizBlock`, `PartialPageTheme` and `PartialBlockTheme`.
## Override nested properties
You can extend an object and add or modify only the properties that you're interested in, without having to re-declare other required properties.
Let's say that you have a data model for employees that contains sensitive salary information. Certain HR team members request data about current employees, including joining dates, to celebrate their anniversaries. But they don't need the salary dimension and are not authorized to access such information. You can extend the original model and hide away the salary dimension by overriding the `hidden` attribute only.
```tsx
// This model contains sensitive employee salary information
Model employeeInfo {
dimension salary {
label: 'Salary'
type: 'number'
hidden: false
definition: @sql {{ #SOURCE.salary }} ;;
}
dimension joining_date {
label: 'Joining Date'
type: 'date'
hidden: false
definition: @sql {{ #SOURCE.joining_date }} ;;
}
}
// This model is exposed to HR team members that need just enough info
// to celebrate employee anniversaries
// highlight-next-line
Model employeeInfoWithoutSalary = employeeInfo.extend({
dimension salary {
// override hidden property without having to re-declare other properties
// highlight-next-line
hidden: true
}
});
```
:::caution Extend cannot be used to remove properties
Extend cannot be used to completely remove properties from objects. The above example only hides the dimension away by modifying its existing hidden property.
:::
## See also
- [AML Reusability Overview](/as-code/aml/reusability-overview) - Conceptual overview and use cases
- [AML Partial](/reference/aml/partial) - Extract shared logic for reuse
---
## AML Model Fields
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dimensions & Measure](/docs/model-fields)
:::
## Dimension
Dimension represents a column in a table or a calculation. You can combine dimensions in the current model into a new dimension.
### Parameter definition
Parameter name | Required | Description
-------------- | -------- | ------------
label | Yes | Specifies how the dimension will appear in the Ready-to-explore Dataset.
type | Yes | Data type of the dimension. Possible values: `'text'`, `'number'`, `'date'`, `'datetime'`, `'truefalse'`, `'json'`, `'unknown'`.
definition | No | How the dimension is calculated, using `@sql` or [`@aql`](/as-code/aql/) syntax. If omitted, Holistics assumes a column with the same name exists in the underlying table.
description | No | Describes the semantic of the dimension.
hidden | No | Default `false`. Hides the dimension from the Exploration interface of Dataset and Report. This is **not a Security Feature** ([Reason](/reference/aml/field#should-i-use-the-hidden-property-in-dimensionmeasure-for-data-restriction-purposes)).
format | No | Display format for the dimension's value. See [Number Format](/reference/aml/number-format) and [Date Format](/reference/aml/date-format).
primary_key | No | Default `false`. Marks this dimension as the primary key of the model. This enables query optimization and supports [Single Model Conditions](/reference/aql/where#single-model-condition) in AQL. Only one dimension per model should have `primary_key: true`.
### Definition of dimension
You can define dimensions using either `@sql` or `@aql` syntax. As a rule of thumb:
- Use **`@sql`** when referencing underlying table columns directly.
- Use **`@aql`** for calculations or transformations based on existing dimensions in the model (it provides better syntax suggestions, typechecking, and validation).
#### SQL definition
- `@sql {{ #SOURCE.column_name }};;` : References a column in the table connected to the current model.
- `@sql {{ dimension_name }};;` : References another dimension defined within the same model.
**Default behavior**: If the definition for a dimension is **not explicitly provided**, Holistics assumes that there is a column in the underlying table with the same name as the dimension.
#### AQL definition
- `@aql` definitions can reference existing dimensions in the current model using the `model.dimension_name` syntax.
- Unlike `@sql`, AQL definitions **cannot** reference underlying table columns directly (only dimensions that are already defined in the model).
### Example of dimension syntax
```aml
Model orders {
type: 'table'
label: "Orders"
table_name: 'ecommerce.orders'
data_source_name: 'mydemodb'
description: "This is the AML Orders Model"
dimension id {
label: 'Order ID'
type: 'number'
primary_key: true // Mark as primary key for optimization and AQL features
definition: @sql {{ #SOURCE.id }};;
}
dimension status {
label: 'Status'
type: 'text'
//to reference the "status" column in the source table
definition: @sql {{ #SOURCE.status }};;
}
dimension created_at {
label: 'Created At'
type: 'datetime'
}
// AQL definition: references the existing "created_at" dimension
dimension created_at_year {
label: 'Created At Year'
type: 'number'
definition: @aql date_part('year', orders.created_at) ;;
}
}
```
## Measure
Measure represents an aggregation operation in a model.
### Parameter definition
Parameter name | Required | Description
-------------- | -------- | ------------
label | Yes | Specifies how the measure will appear in the Ready-to-explore Dataset.
type | Yes | Data type of the measure's result. Possible values: `'text'`, `'number'`, `'date'`, `'datetime'`, `'truefalse'`, `'json'`, `'unknown'`. Typically `'number'`.
definition | Yes | How the measure is calculated. Use `@aql` (recommended) or `@sql`. Learn more below.
description | No | Describes the semantic of the measure.
hidden | No | Default `false`. Hides the measure from the Exploration interface of Dataset and Report. This is **not a Security Feature** ([Reason](/reference/aml/field#should-i-use-the-hidden-property-in-dimensionmeasure-for-data-restriction-purposes)).
aggregation_type | No | Default `'custom'`. Aggregation kind: `'avg'`, `'sum'`, `'count'`, `'count distinct'`, `'min'`, `'max'`, `'median'`, `'stdev'`, `'stdevp'`, `'var'`, `'varp'`, `'running sum'`, `'running avg'`, `'running count'`, `'running min'`, `'running max'`, or `'custom'` (when the `definition` itself is already an aggregation).
format | No | Display format for the measure's value. See [Number Format](/reference/aml/number-format) and [Date Format](/reference/aml/date-format).
### AQL definition of measure
You can use either `@sql` or `@aql` to define measures, but we recommend using `@aql` for most cases as it provides better syntax suggestions, typechecking, and validation.
AQL provides built-in aggregation functions such as `count()`, `sum()`, `avg()`, `min()`, `max()`, `count_distinct()`, `median()`, and more. For a full list, see the [Aggregation Functions](/reference/aql/aggregator-functions) reference.
**Syntax forms:** There are two equivalent ways to write an AQL aggregation:
1. **Function call form** (most common):
```aml
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
```
2. **Pipe form:**
```aml
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql orders.id | count() ;;
}
```
Both produce the same result. Use whichever feels more natural.
**Referencing other measures:** You can create derived measures by referencing other measures in the same model:
```aml
measure average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql total_revenue / total_orders ;;
}
```
:::caution
You **cannot** use dimensions directly in a measure definition without wrapping them in an aggregation function. Every measure must produce an aggregated value.
:::
### Example of measure syntax
```aml
Model orders {
type: 'table'
label: "Orders"
table_name: 'ecommerce.orders'
data_source_name: 'demodb'
dimension id {
label: 'Order ID'
type: 'number'
primary_key: true
definition: @sql {{ #SOURCE.id }};;
}
dimension amount {
label: 'Amount'
type: 'number'
}
// Simple count aggregation
measure total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(orders.id) ;;
}
// Sum aggregation
measure total_revenue {
label: 'Total Revenue'
type: 'number'
definition: @aql sum(orders.amount) ;;
}
// Derived measure referencing other measures
measure average_order_value {
label: 'Average Order Value'
type: 'number'
definition: @aql total_revenue / total_orders ;;
}
}
```
## FAQs
### Should I use the 'hidden' property in dimension/measure for data restriction purposes?
The short answer is: **you should not**. The proper way to set up permission control is via **[Column-level Permission](/docs/access-control/column-level-permission)**.
To explain, you can use the `hidden: true` property if you would like a dimension/measure to be accessible during development, but concealed from users in Reporting. This is achieved by hiding it within the Dimension/Measure Selection of the Dataset.
```typescript
dimension id {
label: 'Id'
type: 'number'
hidden: true
definition: @sql {{ #SOURCE.id }};;
}
```
However, this method should not be applied as a means to restrict others from using these dimensions/measures in Reporting.
Despite being hidden, these dimensions/measures remain accessible through [Dashboard Filters](/docs/filters/field-filters), [Dataset Relationship](/docs/datasets/dataset-relationships), or [AQL Expression](/as-code/aql/).
Therefore, using the 'hidden' property **primarily serves to declutter the Dimensions/Measures list in your Dataset**.
Therefore, if you want to restrict access control and disallow certain users to see certain Dimensions/Measures, **you should use [Column-level Permission](/docs/access-control/column-level-permission)** instead.
---
## AML FilterBlock
FilterBlock adds a user-controlled filter to the dashboard. Viewers interact with it to narrow down the data shown in connected visualization blocks.
## Syntax
```aml
block : FilterBlock {
label: 'Filter Label'
type: 'field'
source: FieldFilterSource {
dataset:
field: r(.)
}
default { // optional
operator: '...'
value: '...'
}
}
```
## Parameters
Parameter | Description
--- | ---
`label` | Display name shown on the filter control.
`type` | Filter type. Use `'field'` to filter on a dataset field.
`source` | A [`FieldFilterSource`](#fieldfiltersource) specifying which field to filter on.
`default` | Optional default filter value applied when the dashboard loads.
## FieldFilterSource
`FieldFilterSource` points to the field the filter controls.
Parameter | Description
--- | ---
`dataset` | The dataset name.
`field` | Reference to the field using `r(model.field)` syntax.
## default
The `default` block sets the initial filter state when the dashboard loads.
Parameter | Description
--- | ---
`operator` | How to apply the filter. Common values: `'is'`, `'matches'`, `'matches_user_attribute'`.
`value` | The default filter value. Can be a string, a list, or a relative date expression.
## Examples
### Date range filter with default
```aml
block date_filter: FilterBlock {
label: 'Order Date'
type: 'field'
source: FieldFilterSource {
dataset: ecommerce
field: r(orders.created_at)
}
default {
operator: 'matches'
value: 'last 30 days'
}
}
```
### Categorical filter with multi-value default
```aml
block status_filter: FilterBlock {
label: 'Order Status'
type: 'field'
source: FieldFilterSource {
dataset: ecommerce
field: r(orders.status)
}
default {
operator: 'is'
value: ['active', 'pending']
}
}
```
### Filter with user attribute default
```aml
block region_filter: FilterBlock {
label: 'Region'
type: 'field'
source: FieldFilterSource {
dataset: ecommerce
field: r(orders.region)
}
default {
operator: 'matches_user_attribute'
value: 'region'
}
}
```
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax including interactions
- [Parameter Fields](/docs/modeling/param-fields): bind filters to model param fields
---
## AML Function
:::caution
AML Function cannot return models and datasets for now due to internal technical limitations. You can use [AML Extend](/reference/aml/extend) to reuse models and datasets instead.
:::
## Introduction
An AML function is a reusable block of code designed to perform a specific task. It takes inputs (called parameters), processes them, and returns an output.
## Syntax
```tsx
Func (
((: )?( = )?)*
) (=> ) {
}
```
* The return [type](/reference/aml/types) is optional as most of the time it can be inferred
* The last expression will be returned as the output, there is no explicit `return` keyword
* Parameters can declare default values using `= `
* Arguments can be passed positionally or by name (see Parameters and arguments below)
## Function declarations
```aml
Func sum(x: Number, y: Number) {
x + y
}
Func double(x: Number) {
const multiple = 2
x * multiple
}
Func myvizBlockWithDataset(dataset: String) { // auto infer return type = VizBlock
VizBlock {
label: 'A pie chart'
viz: PieChart {
dataset: dataset // use the dataset parameter here instead of hard-coding
legend: r(public_users.role)
series {
field {
ref: r(public_users.id)
aggregation: 'sum'
}
}
}
}
}
// equivalent to
Func myvizBlockWithDataset(dataset: String) => VizBlock { // explicitly declare return type
VizBlock {
...
}
}
```
## Parameters and arguments
### Parameter types
#### Required parameters
Required parameters must be specified when calling the function and do not have default values.
```tsx
Func chart(
title: String, // Required
data: Dataset // Required
) { /* Implementation */ }
```
#### Optional parameters
Optional parameters have default values and can be omitted when calling the function.
```tsx
Func sticky_note(
content: RichText,
color: Color = '#fff', // Optional with default
size: Size = 'medium' // Optional with default
) { /* Implementation */ }
```
### Parameter order rules
- Required parameters must be placed before optional parameters
- Optional parameters cannot appear before required parameters (invalid)
### Argument passing
#### Positional arguments
Arguments can be passed in the order parameters are defined:
```tsx
sticky_note('Dashboard 1', '#eee', 'small') // All arguments specified
sticky_note('Dashboard 2') // Using defaults for optional params
```
#### Named arguments
Arguments can be specified by parameter name:
```tsx
sticky_note(
content='Dashboard',
color='#eee',
size='large'
)
```
### Argument passing rules
- Positional arguments must come before any named arguments
- Once you start using named arguments, all subsequent arguments must also be named
- Optional parameters can be omitted to use their default values
- When skipping optional parameters in the middle, named arguments must be used for remaining parameters
### Examples
#### Valid
```tsx
// Assume this function signature for examples below:
Func chart(title: String, data: Dataset, width: Number = 800, height: Number = 600) { /* ... */ }
// All positional
chart("Sales Report", salesData, 1000, 800)
// Mix of positional and named
chart("Sales Report", salesData, width=1000)
// Skip optional param, use named for later param
chart("Sales Report", salesData, height=800)
// All named arguments
chart(
title="Sales Report",
data=salesData,
width=1000,
height=800
)
```
#### Invalid
```tsx
// Named argument before positional
chart("Sales Report", width=1000, salesData)
// Wrong position for positional args
chart(salesData, "Sales Report")
```
## Calling functions
A function will be executed when it is called:
```aml
myvizBlockWithDataset('ecommerce')
// return value
/*
VizBlock {
label: 'A pie chart'
viz: PieChart {
dataset: 'ecommerce'
legend: r(public_users.role)
series {
field {
ref: r(public_users.id)
aggregation: 'sum'
}
}
}
}
*/
```
## Function scope
- Declaring a function creates a new scope. Variables and parameters declared inside a function are not accessible from outside it. However, a function can access all variables and functions defined in the scope where it was created.
```tsx
Func sum_then_double(a: Number, b: Number) => Number {
const factor = 2;
(a + b) * factor
}
a // cannot refer to a here, as it is outside of the function scope
b // ^ same
c // ^ same
```
- Currently, AML allows for the nesting of functions and grants the inner function full access to all the variables and functions defined inside the outer function (and all other variables and functions that the outer function has access to)
- However, the outer function does *not* have access to the variables and functions defined inside the inner function
```tsx
Func sum_then_double(a: Number, b: Number) => Number {
Func sum(a: Number, b: Number) {
a + b // a, b is parameters of inner function sum
}
const factor = 2;
sum(a, b) * factor // a, b is parameters of sum_then_double
}
sum_then_double(2, 3) // 10
```
## Combining with other features
Functions can be combined with other AML features like [string interpolation](/reference/aml/string-interpolation) and [if-else](/reference/aml/if-else) to create powerful reusable templates:
```tsx
Func greet(name: 'John' | 'Alice') {
"Hello, ${if (name == 'John') { 'sir' } else { 'madam'} }!"
}
```
## See also
- [Reusable components in Canvas Dashboard](/docs/canvas-dashboard/reusable-components)
- [Build a Dashboard with Multiple Similar Charts](/docs/canvas-dashboard/build-similar-dashboards)
---
## AML HTML Layout
:::tip Knowledge Checkpoint
A grasp of this concept will help you understand this documentation better: [HTML Layout](/docs/dashboards/html-layout)
:::
`HTMLLayout` is a value for the `view` parameter in a `Dashboard`. Instead of positioning blocks with pixel coordinates, you write HTML and CSS directly to structure the dashboard, and drop blocks into the markup using the `` web component.
```aml
Dashboard sales_overview {
// ... blocks ...
view: HTMLLayout {
content: @html
;;
}
}
```
## Parameters
Parameter | Description
--- | ---
`content` | The HTML markup for the layout. Written as a `@html ... ;;` string. May contain any valid HTML and inline CSS.
## Placing blocks
Use the `` web component to render a block inside the HTML markup. The `name` attribute must match the block identifier declared in the dashboard.
```html
```
Each block is rendered with its default UI (chart, table, filter, text) fully styled. You don't need to specify size or position - the block adapts to the space its container provides.
Every block declared in the dashboard should appear somewhere in the `content` markup. Blocks not referenced by an `` element won't be displayed.
## Layout with CSS
Use standard CSS (flexbox, grid, inline styles, `
;;
}
```
## Full example
For real-world examples, see the [HTML Layout examples](/docs/dashboards/html-layout#examples).
```aml
Dashboard sales_overview {
title: 'Sales Overview'
block heading: TextBlock {
content: @md # Sales Overview ;;
}
block revenue_chart: VizBlock { ... }
block region_filter: CustomControlBlock { ... }
interactions: [
FilterInteraction {
from: 'region_filter'
to: [ CustomMapping { block: 'revenue_chart' } ]
}
]
view: HTMLLayout {
content: @html
;;
}
}
```
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax including blocks, interactions, and settings
- [AML Canvas Layout](/reference/aml/canvas-layout): a single free-form canvas page with pixel-based positioning
- [AML Tab Layout](/reference/aml/tab-layout): tabbed canvas dashboard
---
## AML If-else
## Introduction
AML if-else is a control-flow expression. It evaluates a Boolean condition and returns the value of the executed block. The value of the if-else expression is the value of the last expression inside the executed block.
- When used as a standalone statement, `else` is optional.
- When used within another expression (for example, assignment or string interpolation), the expression must evaluate to a value on all paths; include an `else` (or `else if … else`) branch.
## Syntax
```tsx
// single branch
if (condition) {
expression
}
// with else clauses
if (condition1) {
expression1
} else if (condition2) {
expression2
} else {
expression3
}
```
- Parentheses are required around conditions.
- Braces are required around blocks.
- Conditions must be Boolean expressions.
## Examples
```tsx
const a = 1
if (a > 0) {
'positive'
} else if (a == 0) {
'zero'
} else {
'negative'
}
// returns 'positive'
```
```tsx
const a = 1
const b = if (a > 0) {
'positive'
} else if (a == 0) {
'zero'
} else {
'negative'
}
b // 'positive'
```
String interpolation example:
```tsx
Func greet(name: 'John' | 'Alice') {
"Hello, ${if (name == 'John') { 'sir' } else { 'madam' }}!"
}
```
### Analytics-focused examples
Conditional metric selection in a KPI block:
```tsx
// Renders a KPI block for either revenue (sum) or orders (count)
Func kpi_block(metric: 'revenue' | 'orders') {
const measure_ref = if (metric == 'revenue') {
r(fact_orders.revenue)
} else {
r(fact_orders.id)
}
VizBlock {
label: "KPI - ${metric}" // dynamic label derived from selected metric
viz: SingleValue {
dataset: 'ecommerce'
series {
field {
ref: measure_ref
aggregation: if (metric == 'revenue') { 'sum' } else { 'count' }
}
}
}
}
}
```
Conditional grouping dimension for a distribution chart:
```tsx
// Switches the grouping between product category and country
Func distribution_block(by: 'category' | 'country') {
const group_dim = if (by == 'category') {
r(products.category)
} else {
r(countries.name)
}
VizBlock {
label: "Distribution by ${by}" // dynamic label derived from grouping parameter
viz: PieChart {
dataset: 'ecommerce'
legend: group_dim
series {
field {
ref: r(orders.id)
aggregation: 'count'
}
}
}
}
}
```
## Type of an if-else expression
The type is the union of all possible branch result types.
```tsx
Func gen_dashboard_block(block_type: DashboardBlock) {
if (block_type == VizBlock) {
VizBlock { ... }
} else if (block_type == FilterBlock) {
FilterBlock { ... }
} else {
TextBlock { ... }
}
}
// gen_dashboard_block return type: VizBlock | FilterBlock | TextBlock
```
Guidelines:
- Branches used in an expression context should return compatible types or a meaningful union.
- If a branch yields no value, it cannot be used where a value is required.
## AML comparison operators
- `==`: true if operands are equal
- `!=`: true if operands are not equal
- `>`: true if left is greater than right
- `>=`: true if left is greater than or equal to right
- `<`: true if left is less than right
- `<=`: true if left is less than or equal to right
Examples:
```tsx
3 == 3 // true
4 != 5 // true
5 > 2 // true
2 >= 2 // true
1 < 3 // true
2 <= 1 // false
```
## AML logical operators
- `&&`: true if both operands are true
- `||`: true if at least one operand is true
- `!`: logical negation
Examples:
```tsx
true && false // false
true && true // true
true || false // true
false || false // false
!true // false
!false // true
```
---
## AML Reference
This is the reference for AML: the syntax, the types, the object-by-object details. Use it to look something up.
For *concepts* (what AML is for, design principles, reusability patterns, and how it fits with AQL), head to [AML Overview](/as-code/aml/) in the Documentation section.
## How this section is organized
AML is a declarative language for defining semantic models. The reference is grouped by object type and by language feature, mirroring the sidebar.
## Core objects
The building blocks you'll define most often: models and their fields, datasets, and dashboards.
### Models and fields
Define table models and query models, the foundation of every semantic model.
Models backed by database tables or views.
Models defined by a SQL query rather than a single table.
Materialize model queries as tables for faster reads.
Dimensions and measures defined inside models.
### Datasets
Collections of models wired together with relationships.
Cross-model dimensions and metrics that live on the dataset.
Map model fields to pre-aggregated tables for faster queries.
How models join together inside a dataset.
### Dashboards
Dashboard definitions as code.
TextBlock, VizBlock, FilterBlock, and DateDrillBlock.
DateDrillInteraction and CustomMapping for cross-block behavior.
A single free-form canvas page with pixel-based positioning.
Tabbed canvas dashboard.
Free-form HTML and CSS layout for complex or paginated compositions.
PageTheme, BlockTheme, and VizTheme for styling dashboards.
## Language features
The constructs that make AML composable and reusable: types, functions, modules, and the tools for sharing logic across objects.
The AML type system.
Reusable constant values.
User-defined functions.
Organize and import definitions across files.
Inherit and override object properties.
Composable object fragments you can mix in.
Conditional logic in definitions.
Build dynamic string values.
Runtime context from users and Git environments.
## Quick start
If you're new to AML:
- [Examples](/reference/aml/examples): side-by-side code examples for common objects
- [Model](/reference/aml/model) → [Fields](/reference/aml/field) → [Dataset](/reference/aml/dataset): the core modeling path
## See also
- [AML Overview](/as-code/aml/): conceptual introduction and design principles
- [AQL Reference](/reference/aql/): the query language that runs on top of AML models
- [Reusability Guide](/as-code/aml/reusability-guide): patterns for composing and reusing AML code
---
## AML Metric
A `Metric` defines an aggregated business measure in [AQL](/as-code/aql/). The same `Metric` type can be defined in two places:
- **Inline inside a dataset** — quickest if the metric is only used by one dataset.
- **Standalone at the top level** — reusable; attach the same definition to multiple datasets via [`extend`](/reference/aml/extend).
Both forms share the same parameters and the same `definition` semantics. For an in-model equivalent (a measure defined inside a `Model`), see [Measure](/reference/aml/field#measure). For patterns on organizing reusable metrics, see [Implement Reusable Metric Store](/as-code/aml/use-cases/extend-metric-store).
## Parameter definition
Parameter name | Required | Description
-------------- | -------- | ------------
label | Yes | How the metric appears in the Ready-to-explore Dataset.
type | Yes | Data type of the metric's result. Possible values: `'text'`, `'number'`, `'date'`, `'datetime'`, `'truefalse'`, `'json'`, `'unknown'`. Typically `'number'`.
definition | Yes | The metric expression, written in [AQL](/as-code/aql/). Must produce an aggregated value. **AQL only** — unlike a measure, a standalone `Metric` cannot use `@sql`.
description | No | Describes the semantic of the metric. Surfaced in tooltips in the exploration UI.
format | No | Display format for the metric's value. See [Number Format](/reference/aml/number-format) and [Date Format](/reference/aml/date-format).
hidden | No | Default `false`. Hides the metric from the Exploration interface when attached. Not a security feature ([reason](/reference/aml/field#should-i-use-the-hidden-property-in-dimensionmeasure-for-data-restriction-purposes)).
## Where to define
The body of a `Metric` is identical whether you write it inline in a dataset or as a standalone top-level object.
**Inline in a dataset:**
```aml title="ecommerce.dataset.aml"
Dataset ecommerce {
// ... models, relationships, dimensions ...
metric count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
metric sum_order_value {
label: "Sum Order Values"
type: "number"
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}
}
```
**Standalone, then attached to a dataset:**
```aml title="metrics.aml"
Metric count_orders {
label: "Count Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
```
```aml title="ecommerce.dataset.aml"
Dataset ecommerce_with_metrics = ecommerce.extend({
metric count_orders: count_orders
})
```
Prefer the standalone form when the same metric is needed across more than one dataset — change the definition once and every dataset that references it picks up the update. See [Where to define AQL](/as-code/aql/where-to-define-aql) for a fuller comparison.
## Definition
A `Metric`'s `definition` is written in AQL (`@aql ... ;;`). Like a measure, it must produce an aggregated value (use `sum()`, `count()`, `avg()`, etc., or reference another metric that does). For the full list of aggregators, see [Aggregation Functions](/reference/aql/aggregator-functions).
Standalone metrics reference models and dimensions directly by name; they are not scoped to a single model.
```aml
Metric gmv {
label: "GMV (Gross Merchandise Value)"
type: "number"
description: "Total value of all orders before discount"
definition: @aql ecommerce_order_items | sum(ecommerce_order_items.quantity * ecommerce_products.price) ;;
format: "[\$\$]#,###0"
}
```
A metric can reference another metric in its definition, letting you build derived metrics:
```aml
Metric total_orders {
label: "Total Orders"
type: "number"
definition: @aql count(ecommerce_orders.id) ;;
}
Metric aov {
label: "AOV (Average Order Value)"
type: "number"
definition: @aql gmv / total_orders ;;
format: "[\$\$]#,###0"
}
```
## Attaching to a dataset
A standalone `Metric` becomes usable in exploration only after it's attached to a dataset via [`extend`](/reference/aml/extend):
```aml title="sales.dataset.aml"
Dataset sales_with_metrics = sales.extend({
metric gmv: gmv
metric total_orders: total_orders
})
```
The same metric can be attached to any number of datasets. Change the `Metric` definition once, and every dataset that references it picks up the update.
## Example
```aml title="metrics.aml"
Metric total_orders {
label: "Total Orders"
type: "number"
description: "Total number of orders placed"
definition: @aql count(ecommerce_orders.id) ;;
}
Metric gmv {
label: "GMV (Gross Merchandise Value)"
type: "number"
description: "Total value of all orders before discount"
definition: @aql ecommerce_order_items | sum(ecommerce_order_items.quantity * ecommerce_products.price) ;;
format: "[\$\$]#,###0"
}
Metric aov {
label: "AOV (Average Order Value)"
type: "number"
definition: @aql gmv / total_orders ;;
format: "[\$\$]#,###0"
}
```
```aml title="company.dataset.aml"
Dataset company_with_metrics = company.extend({
metric total_orders: total_orders
metric gmv: gmv
metric aov: aov
})
```
## See also
- [Where to define AQL](/as-code/aql/where-to-define-aql): all the places a metric can live (in a model, in a dataset, standalone, or ad-hoc)
- [Implement Reusable Metric Store](/as-code/aml/use-cases/extend-metric-store): patterns for organizing standalone metrics, including `PartialDataset` groupings and parameterization with `Func`
- [AML Extend](/reference/aml/extend): the mechanism for attaching standalone metrics to datasets
- [Measure (`field` reference)](/reference/aml/field#measure): the in-model equivalent
---
## AML Model
## Introduction
A Model in AML represents either a database table or a SQL query, and contains dimensions and measures that define how data can be explored and aggregated. Models are declared in `.model.aml` files (one model per file).
## Model types
AML supports two types of models:
- **[Table Model](/reference/aml/table-model)**: Built on top of a physical database table
- **[Query Model](/reference/aml/query-model)**: Built on top of a custom SQL query
## Common parameters
These parameters are shared by both Table Models and Query Models:
Parameter name | Description
-------------- | ------------
type | Model type: `'table'` for Table Model, `'query'` for Query Model
label | User-friendly name for the model that appears in the Dataset exploration UI
description | Description of the model. Support Markdown.
data_source_name | The database on which Holistics will execute the SQL generated from this model
owner | Define who should be in charge of managing the current model
dimension | Define a dimension. See [Model Fields](/reference/aml/field)
measure | Define a measure. See [Model Fields](/reference/aml/field)
## Basic syntax
```aml
Model model_name {
type: 'table' // or 'query'
label: "Model Label"
description: "Model description here"
data_source_name: 'your_datasource_name'
owner: 'user@your-domain.com'
dimension dimension_name {
label: 'Dimension Label'
type: 'text'
definition: @sql {{ #SOURCE.column_name }};;
}
measure measure_name {
label: 'Measure Label'
type: 'number'
definition: @aql count(model_name.dimension_name) ;;
}
}
```
## See also
- [Table Model](/reference/aml/table-model)
- [Query Model](/reference/aml/query-model)
- [Model Fields](/reference/aml/field)
---
## AML Module
## Introduction
An AML module is a directory containing similar AML objects and functions together. The files can be arbitrarily nested.
For example, they could be **a set of objects type** (models, dataset) **or functions** (cohort, mrr, recurring, etc) **that you can reuse** throughout your AML project.
An AML module can contain sub-modules, these modules must be placed within `modules` directory in the project's root path.
Using modules, you would be able to follow Modular Data Modeling’s best practices. Organizing related concepts together helps create a neat, uniform structure for your projects. They allow for easier code refactoring and future code reuse.
## How to create AML modules
All AML modules are defined under the directory **modules**. For each sub-module, users need to create a new directory under `modules/`. The name of the module will be the name of the directory.
For example, the **ecommerce** module will be `modules/ecommerce/file.aml`
## How to use AML modules
### Refer to objects in submodules
- **Syntax:**
```tsx
.
```
- **Example:**
```tsx
//AML project
|-- datasets
| |-- datasets.aml
|-- models
| |-- base
| | |-- customers.model.aml
|-- modules
| |-- cohort
| | |-- package.aml
| | |-- cohort.aml
| | |-- ...
| |-- utils
| |-- ...
|
```
Dataset can refer to models in the same module or refer to models in the sub-module like this:
```tsx
// models/base/customers.model.aml:
Model customers {
...
}
// modules/cohort/cohort.aml:
Model retetion_table {
...
}
// datasets/datasets.aml:
Dataset customer_details_aml {
label: 'Customer Details (AML ver.)'
description: ''
data_source_name: 'bigquerydw'
models: [
customers, // refer to customers model in the same module
cohort.retention_table // refer to retention_table model in cohort submodule
]
relationships: [
...
]
owner: 'ha.pham+internal@holistics.io'
}
```
### Import AML modules with the `use` keyword
- Users can bring module items into the current file’s scope by using the `use` statement (these items are **only visible in the current file only**, not in the whole current module)
- Syntax:
```tsx
use { (: )? }
```
- Example:
```rust
// AML project
|-- modules
| |-- cohort
| | |-- retention_table.aml
| | |-- cohort_items.aml
| |-- utils
| | |-- date_time.aml
| | |-- modules
| | | |-- region
| | | | |-- region_items.aml
```
```rust
use cohort { retention_table, cohort_items: items } // alias cohort_items to items
use utils { date_time }
use utils.region // bring all the objects in the module utils.region into this file
```
## Object names are unique within the module scope
- An AML module must be explicitly named or named after the contained directory name by default
- **The name of an AML object must be unique within a single module**, except the objects within its nested modules.
For example,
```rust
|-- models
| |-- some.model.aml
|-- datasets
| |-- some.dataset.aml
|-- modules
| |-- client_1
| | |-- models
| | | |-- users.model.aml
| | | |-- orders.model.aml
| | |-- datasets
| | | |-- ecommerce.dataset.aml
| |-- client_2
| | |-- models
| | | |-- users.model.aml
| | | |-- orders.model.aml
| | |-- datasets
| | | |-- ecommerce.dataset.aml
```
Your whole AML project is a root module. Inside `modules/` directory, `client_1` is a sub-module, and `client_2` is another sub-module with the same hierarchy as `client_1` module. The scope of `client_1` is different from that of `client_2` so that the model name in `client_1` could be the same as `client_2`.
---
## Number Format
:::info Note
To apply **Number Format** for your dimension/measure, ensure its `type` is `number`.
:::
## Overview of Number Format
To format your number fields, add a **format** property with a string pattern representing your chosen format.
```json
dimension formatted_price {
label: "Formatted Price"
type: "number"
format: "#,###[$$]"
}
```

## Shorthand Syntax vs. Full Syntax
Normally, you would use the shorthand version, which is represented as a string pattern under the `format` field. However, if you want **more control over your formats**, consider using the full syntax instead as it offers additional options to choose from.
Shorthand format example
```json
dimension price {
label: "price"
type: "number"
description: "This dimension is using the shorthand format syntax"
format: "[$$]#,###"
}
```
Full format example
```jsx
dimension price {
label: "price"
type: "number"
description: "This dimension is using the full number format syntax"
format {
pattern: '[$$]#,###0.00,,"\M"'
// There are more options to choose from!
groupSeparator: " "
decimalSeparator: "."
}
}
```
## Order for Number Format
The number format follows the order below.

## Available Number Format options
### currency (optional)
:::info Note
abbreviation/currency **cannot be used with** percentage.
:::
**Syntax:**
```json
[$]
```
**Note:** `\$` and `$` can be used interchangeably.
**Description:**
- The currency notation must be wrapped in **$<insert_your_currency_here>.**
- The currency notation can only be placed in one of these places:
- **At the beginning** of the string pattern.
- **At the end** of the string pattern.
- Currently, we only support these currency types:
```tsx
'$' // US Dollar / MEX Peso
'€' // Euro
'¥' // Yen
'£' // Pound
'元' // Renminbi
'₺' // Lira
'₩' // Won
'₽' // RUS Ruble
'₹' // IND Rupee
'₨' // PAK Rupee
'₱' // PHL Peso
'A$' // AUS Dollar
'C$' // CAN Dollar
'S$' // SGP Dollar
'NZ$' // NZ Dollar
'HK$' // HK Dollar
'₪' // Shekel
'R$' // Real
'฿' // Thai Baht
'R' // South African Rand
'Fr' // Franc
'kr' // Krona / Krone
'Ft' // Forint
'Rp' // Rupiah
'RM' // Ringgit
'VND' // VN Dong
'Tk' // BGD Taka
```
If your currency isn't in the list, you can enter any custom symbol using the `[$...]` syntax (for example, `[$₿]` for Bitcoin).
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 54 | [$$]#,### | $54 |
| 1236 | #,###[$€] | 1,236€ |
### group separator for integer values (optional)
:::info Note
**Holistics will automatically apply thousands separator for integers** whether you specify this option or not.
:::
**Syntax:**
```ruby
#,###
```
**Description:**
- Use this pattern to group your integer digits. Only **thousands grouping** is supported (a separator every three digits). There is no separate millions or billions grouping mode.
- In shorthand syntax the separator is **always a comma** (`,`). Writing the pattern with a different character (such as `#.###`) does not change the separator. To use a space or a dot, switch to the full syntax and set [`groupSeparator`](#groupseparator).
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 586347 | #,### | 586,347 |
| 1234 | #,### | 1,234 |
| 23431 | #,### | 23,431 |
### fraction (optional)
```json
0.0 (add more 0 if more decimal places are needed)
```
**Description:**
- Specify how many numbers should be included in the fraction format.
- The default separator is a dot(`.`). To specify another separator, refer to [Full Format: Custom Decimal Separator](#decimalseparator).
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 123456789.0123 | 0.00 | 123456789.01 |
### percentage (optional)
:::info Note
abbreviation/currency **cannot be used with** percentage.
:::
**Syntax:**
```json
% or \\%
```
**Description:**
- `%` converts a number to percentage format **by multiplying the original value** by 100.
- `\\%` converts a number to percentage format **without modifying the original value**.
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 12.3456 | `#,###0.0%` | 1,234.6% |
| 12.3456 | `#,###0.0\\%` | 12.3% |
### abbreviation (optional)
:::info Note
abbreviation/currency **cannot be used with** percentage.
:::
**Syntax:**
Pick **one** of the following tokens to abbreviate long numeric values:
- `"A"`: auto-select (K, M, or B based on the value)
- `,"K"`: thousand
- `,,"M"`: million
- `,,,"B"`: billion
**Description:**
- If your string pattern is wrapped in **single quotes (’ ‘)**, you **will not** need to wrap your abbreviation with **escape characters(`\ \`)**. For example, this is a valid pattern: `format: ',"K"'`
- If your string pattern is wrapped in **double quotes (” “)**, you **will** have to wrap your abbreviation with **escape characters(`\ \`)**. For example, this is a valid pattern: `format: ",\"K\""`
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 124412.4 | '#,###0.000,"K"' | 124.412K |
| 157698 | '#,###0.000,,"M"' | 0.158M |
## Additional options for Full Syntax Number Format
To use these options, specify your Number Format in [Full Syntax form](#shorthand-syntax-vs-full-syntax).
### groupSeparator
This option allows you to select a **custom separator** **for your integer values**.
**Syntax:**
```json
format {
pattern: "#,###"
groupSeparator: ","
}
```
**Description:**
- By default, the group separator for integer values is a comma `,`. To opt for another separator, add this option in your **format** field.
- **Selectable separators** include:
- `","`: comma separator (default)
- `" "`: space separator
- `"."`: dot separator
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 543213 | groupSeparator: " " | 543 213 |
| 12345.1 | groupSeparator: "," | 12,345.1 |
### decimalSeparator
This option allows you to select a **custom separator** **for your decimal values**.
**Syntax:**
```json
format {
pattern: "0.00"
decimalSeparator: "."
}
```
**Description:**
- By default, the group separator for decimal values is a dot `.`. To opt for another separator, add this option in your **format** field.
- **Selectable separators** include:
- `"."`: dot separator (default)
- `","`: comma separator
**Example:**
| Raw Value | Number Pattern | Displayed as |
| --- | --- | --- |
| 54.123 | decimalSeparator: "," | 54,123 |
---
## AML Partial
## Introduction
Partial allows you to **extract the shared logic into a new object**, making it ready for reuse across multiple objects.
```aml {title='Example: Model partial to reuse timestamp dimensions'}
// highlight-start
// partial_timestamps.aml -- Reusable timestamp dimensions
PartialModel partial_timestamps {
dimension created_at {
type: 'datetime'
definition: @sql {{ #SOURCE.created_at }};;
}
dimension updated_at {
type: 'datetime'
definition: @sql {{ #SOURCE.updated_at }};;
}
}
// highlight-end
// users_base.model.aml -- Base model
Model users_base {
type: 'table'
table_name: 'public.users'
data_source_name: 'demodb'
dimension id { ... }
dimension username { ... }
dimension email { ... }
}
// users.model.aml -- Final model composing base + timestamps
// highlight-next-line
Model users = users_base.extend(partial_timestamps)
```
AML Partial is commonly used in conjunction with [AML Extend](/reference/aml/extend). See syntax and example usages below for more details.
Many Holistics' built-in types support AML Partial: `PartialModel`, `PartialTableModel`, `PartialQueryModel`, `PartialDataset`, `PartialDashboard`, `PartialVizBlock`, `PartialPageTheme` and `PartialBlockTheme`.
## Syntax
```aml
// Declaring a partial object
// type can be any of supported types: Model, TableModel, QueryModel, Dataset,
// Dashboard, VizBlock, PageTheme, BlockTheme
Partial {
// properties
}
// Usage: extend a named object with the partial
= .extend()
// Chaining multiple partials
=
.extend()
.extend()
```
## Example usages
### Reusing metrics across multiple datasets
Our recommended approach is to split things into separate files for better organization:
```aml title="revenue_metrics.aml"
// File 1: Reusable metrics only
PartialDataset revenue_metrics {
metric gmv { ... }
metric mrr { ... }
metric arr { ... }
}
```
```aml title="company_base.dataset.aml"
// File 2: Base dataset with models and relationships
Dataset company_base {
label: "Company Base"
models: [...]
relationships: [...]
}
```
```aml title="company.dataset.aml"
// File 3: Final dataset that composes the base + metrics
Dataset company = company_base
.extend(revenue_metrics)
.extend({
label: "Company"
})
```
You can reuse the same partial across different datasets:
```aml title="store.dataset.aml"
Dataset store = store_base.extend(revenue_metrics)
```
### Reusing widgets across multiple dashboards
```aml title="common_widgets.aml"
PartialDashboard common_widgets {
block revenue_chart: VizBlock { ... }
block sales_chart: VizBlock { ... }
}
```
```aml title="dashboard1.dashboard.aml"
Dashboard dashboard1_base {
block new_chart: VizBlock { ... }
}
Dashboard dashboard1 = dashboard1_base.extend(common_widgets)
```
```aml title="dashboard2.dashboard.aml"
Dashboard dashboard2_base {
block another_chart: VizBlock { ... }
}
Dashboard dashboard2 = dashboard2_base.extend(common_widgets)
```
---
## AML Persistence
:::tip Persistence can be found in
- [Query Model](/docs/persistence)
- [Pre-Aggregate](/docs/aggregate-awareness/persistence)
:::
## Persistence settings
### Syntax format
```aml
persistence: FullPersistence | IncrementalPersistence {
schema: 'schema_name'
view_name: 'view_name'
// Parameters for IncrementalPersistence
incremental_column: 'updated_at'
primary_key: 'id'
on_cascade: 'rebuild' | 'reuse'
// Non-select query to optimize persisted table
custom_ddl: @sql ... ;;
}
```
### FullPersistence
Example:
```aml
persistence: FullPersistence {
schema: 'scratch' // persisted table will be written into this schema
view_name: 'pqm_orders' // Optional
}
```
| Parameter name | Requirement | Description |
| ------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schema | required | Schema name that the persisted table will be written into |
| view_name | optional | If specified, the persisted table will have this name. If not, Holistics will automatically generate a name for the table. |
| on_cascade | optional | Specify the behavior of a model persistence when being triggered by a downstream model. Options: `rebuild`, `reuse`. Default: `rebuild`. [Learn more](/docs/persistence#flow-based-cascading-persistence) |
| custom_ddl | optional | By declaring the `custom_ddl` (Custom Data Definition Language) in a persistence config, you can customize the way the persistence table is created in your Data Warehouse. [Learn more](/docs/persistence#persistence-table-optimizations) |
### IncrementalPersistence
Example:
```aml
persistence: IncrementalPersistence {
// Incremental only settings
incremental_column: 'updated_at'
primary_key: 'id'
// other options are the same as FullPersitence
}
```
| Parameter name | Requirement | Description |
| ------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schema | required | Schema name that the persisted table will be written into |
| view_name | optional | If specified, the persisted table will have this name. If not, Holistics will automatically generate a name for the table. |
| incremental_column | required | Values of this column will be used to check for new records. |
| primary_key | optional | Holistics will refer to this column to replace changed records in the current persisted table. |
| on_cascade | optional | Specify the behavior of a model persistence when being triggered by a downstream model. Options: `rebuild`, `reuse`. Default: `rebuild`. [Learn more](/docs/persistence#flow-based-cascading-persistence) |
| custom_ddl | optional | By declaring the `custom_ddl` (Custom Data Definition Language) in a persistence config, you can customize the way the persistence table is created in your Data Warehouse. [Learn more](/docs/persistence#persistence-table-optimizations) |
## Schedule settings
:::info Steps
To set schedules to run persistences
1. Create a `schedules.aml` file **at the root of your AML project**.
2. Inside `schedules.aml`, define the schedules in a constant named `schedules`.
:::
### Schedule for Query Model Persistence
| AML Type | `Schedule` |
| :------- | :--------- |
| Parameter name | Requirement | Description |
| :------------- | :---------- | :---------------------------------------------------- |
| cron | required | [Cron schedule expression](#cron-schedule-expression) |
| models | required | List of Query Models to persist |
Sample content of a `schedules.aml` file for scheduling [Query Model Persistences](/docs/persistence):
```aml
const schedules = [
// Schedule to persist the Model `model_a` every 10 minutes
Schedule { cron: '0,10,20,30,40,50 * * * *', models: [model_a] }
// We can define another schedule using a different interval
Schedule { cron: '0 * * * *', models: [model_b] }
// We can also set multiple models to use the same schedule
Schedule { cron: '0 * * * *', models: [model_c, model_d] }
]
```
### Schedule for PreAggregate Persistence
| AML Type | `PreAggregateSchedule` |
| :------- | :--------------------- |
| Parameter name | Requirement | Description |
| :------------- | :---------- | :---------------------------------------------------------------------------------------------------------------------------------- |
| cron | required | [Cron schedule expression](#cron-schedule-expression) |
| object | required | Object containing the PreAggregates(Currently, can only be a Dataset) |
| pre_aggregates | optional | List of PreAggregate names (in the `object`) to persist.Omit or leave this empty to persist all PreAggregates in the `object`. |
Sample content of a `schedules.aml` file for scheduling [Pre-Aggregate Persistences](/docs/aggregate-awareness/quick-start#define-the-pre-aggregate):
```aml
const schedules = [
// Schedule for specific PreAggregates in a Dataset
PreAggregateSchedule {
cron: '15 20 * * *'
object: ecommerce_dataset
pre_aggregates: ['agg_transactions'] // persist the PreAggregate 'agg_transactions' only
}
// Schedule for all PreAggregates in a Dataset
PreAggregateSchedule {
cron: '15 20 * * *'
object: ecommerce_dataset
}
]
```
### Schedule for multiple types of persistences
You can schedule multiple types of persistences within the `schedules` list.
Sample content of a `schedules.aml` file with multiple types of schedules:
```aml
const schedules = [
Schedule {
cron: '0 * * * *'
models: [model_c, model_d]
}
PreAggregateSchedule {
cron: '15 20 * * *'
object: ecommerce_dataset
}
]
```
### Cron schedule expression
:::tip Cron schedule expression
Here are a few links to help you get used to cron schedule expression:
- https://crontab.guru: translate cron expression to natural language
- https://crontab.guru/examples.htm: examples of frequently used expressions
:::
---
## AML PreAggregate
`PreAggregate` maps your model's fields to columns in a pre-aggregated table, enabling Holistics to automatically use that table for matching queries instead of scanning the raw data.
`PreAggregate` objects live inside the `pre_aggregates` block of a `Dataset`. They can also be declared standalone (outside the dataset) for reuse with [AML Extend](/reference/aml/extend).
For a conceptual introduction, see [Aggregate Awareness](/docs/aggregate-awareness).
## Syntax
```aml
Dataset ecommerce {
// ...
pre_aggregates: {
: PreAggregate {
dimension {
for: r(model.field)
time_granularity: 'day' // optional, for date/datetime fields
}
measure {
for: r(model.field)
aggregation_type: 'count'
}
persistence: FullPersistence { schema: 'persisted' } // or ExternalPersistence
}
}
}
```
## Dimension mapping
Each `dimension` block maps a model field to a column in the pre-aggregated table. The dimension name must match the column name in the pre-aggregated table.
Parameter | Requirement | Description
--- | --- | ---
`for` | required | Reference to the source model field using `r(model.field)` syntax.
`time_granularity` | optional | For date/datetime fields, the granularity this pre-aggregate was built at. Holistics uses this table for queries at that granularity or coarser. Accepted values: `'year'`, `'quarter'`, `'month'`, `'week'`, `'day'`, `'hour'`, `'minute'`.
## Measure mapping
Each `measure` block maps an aggregation to a column in the pre-aggregated table. The measure name must match the column name in the pre-aggregated table.
Parameter | Requirement | Description
--- | --- | ---
`for` | required | Reference to the source model field using `r(model.field)` syntax.
`aggregation_type` | required | The aggregation function used when building the pre-aggregated table. Accepted values: `'count'`, `'count_distinct'`, `'sum'`, `'avg'`, `'max'`, `'min'`.
## Persistence
The `persistence` parameter tells Holistics where the pre-aggregated table lives.
### ExternalPersistence
Use this when the table already exists in your warehouse (built by dbt, Airflow, or any external process).
```aml
persistence: ExternalPersistence {
table_name: 'your_schema.your_aggregated_table'
}
```
Parameter | Description
--- | ---
`table_name` | Fully qualified table name in your data warehouse.
:::important
Dimension and measure names in your `PreAggregate` config must exactly match the column names in your external table.
:::
### FullPersistence and IncrementalPersistence
Use these to let Holistics create and refresh the table automatically. They accept the same parameters as [model persistence](/reference/aml/persistence).
```aml
persistence: FullPersistence {
schema: 'persisted'
}
// or for incremental refresh:
persistence: IncrementalPersistence {
schema: 'persisted'
incremental_column: 'created_at'
}
```
See [Pre-aggregate Persistence](/docs/aggregate-awareness/persistence) for setup instructions and scheduling.
## Example
```aml
Dataset ecommerce {
models: [transactions]
pre_aggregates: {
agg_transactions_daily: PreAggregate {
dimension created_at {
for: r(transactions.created_at)
time_granularity: 'day'
}
dimension status {
for: r(transactions.status)
}
dimension country {
for: r(transactions.country)
}
measure count_transactions {
for: r(transactions.id)
aggregation_type: 'count'
}
measure total_revenue {
for: r(transactions.revenue)
aggregation_type: 'sum'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
}
}
```
## Standalone declaration with Extend
`PreAggregate` can be declared outside of a `Dataset` as a named object, then extended to create variants. This avoids repeating shared measures and persistence across multiple granularities.
```aml
PreAggregate agg_base {
measure count_transactions {
for: r(transactions.id)
aggregation_type: 'count'
}
persistence: IncrementalPersistence {
schema: 'persisted'
incremental_column: 'created_at'
}
}
Dataset ecommerce {
pre_aggregates: {
agg_daily: agg_base.extend({
dimension created_at { for: r(transactions.created_at), time_granularity: 'day' }
}),
agg_monthly: agg_base.extend({
dimension created_at { for: r(transactions.created_at), time_granularity: 'month' }
})
}
}
```
See [Build multiple pre-aggregates](/as-code/aml/use-cases/build-multiple-pre-aggregates) for a full walkthrough.
## See also
- [Aggregate Awareness](/docs/aggregate-awareness): concept guide and how the query rewriting works
- [Quick Start](/docs/aggregate-awareness/quick-start): step-by-step setup guide
- [Pre-aggregate Persistence](/docs/aggregate-awareness/persistence): built-in vs external persistence
- [AML Persistence](/reference/aml/persistence): `FullPersistence` and `IncrementalPersistence` parameter reference
- [AML Dataset](/reference/aml/dataset): the `pre_aggregates` block lives here
- [Build multiple pre-aggregates](/as-code/aml/use-cases/build-multiple-pre-aggregates): using Extend to reduce repetition
---
## AML Query Model
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Query Model](/docs/query-models)
:::
## Introduction
[Query Model](/docs/query-models) defines a data model that sits on top of a query.
## Parameter definition
Parameter name | Description
-------------- | ------------
type | Model type: `'table'` for Table Model, `'query'` for Query Model
label | User-friendly name for the model that appears in the Dataset exploration UI
description | Description of the model. Support Markdown.
data_source_name | The database on which Holistics will execute the SQL generated from this model
owner | Define who should be in charge of managing the current model
models | Add models to be used in the querying
query | The query of the model
dimension | Define a dimension.
measure | Define a measure.
param | Define [Query Parameters](/docs/query-parameters) for the model's query.
persistence | Define [Persistence](/docs/persistence) settings for the model.
## Query Syntax
The general form of a query is as follows:
```sql
select
{{ #model_1.field_name }} as field_alias_1,
{{ #model_2_alias.field_name }} as field_alias_2
from {{ #model_1 }}
left join {{ model_2 as model_2_alias }}
on {{ #model_1_alias.field_name }} = {{ model_2.field_name }};;
```
Explanation:
Syntax | Action
-------|--------
`{{ #model_name }}` | Refer to another model. **REQUIRED** if you want to enforce [model dependency](/docs/query-models#model-dependencies).
`{{ #model_name as alias }}` | Set an alias for a model reference (place alias inside the curly brackets)
`{{ #model_name.field_name }}` or `{{ #alias.field_name }}` | Field selection (dimension or measure)
`{{ #model_name.* }}` | Get all fields of a model
## Note on Field Selection syntax
:::tip
We recommend you **always use Field Selection syntax** for better query performance
:::
When converting from Holistics's query syntax to the final SQL, if you use the **Field Selection** syntax (`{{ #model_name.field_name }}`), Holistics's engine will be able to select only the table fields you require from the start.
Without the Field Selection syntax, the default behavior is to select all fields of the underlying table, and then select the required field from the result set.
```sql
select
{{ #l.id }}
, {{ #l.name }}
, {{ #l.property_type }}
, {{ #l.room_type }}
from {{ #homestay_listings as l }}
```
The resulted query includes only necessary fields:

```sql
select
id
, name
, property_type
, room_type
from {{ #homestay_listings }}
```
The resulted query includes all the fields:

This is particularly important when you query from "fat tables" with large number of columns.
## Code Example
```aml
Model location {
type: 'query'
label: 'Location'
data_source_name: 'demodb'
models: [cities, countries]
query: @sql
select
{{ #ci.id }} as city_id,
{{ #ci.name }} as city_name,
{{ #co.code }} as country_code,
{{ #co.name }} as country_name,
{{ #co.continent_name }}
from {{ #cities as ci }}
left join {{ #countries as co }} on {{ #co.code }} = {{ #ci.country_code }};;
persistence: FullPersistence {
schema: 'scratch'
view_name: 'cities_countries'
}
dimension city_id {
label: 'City Id'
type: 'number'
primary_key: true // recommended for query optimization and AQL features
}
dimension city_name {
label: 'City Name'
type: 'text'
}
dimension country_code {
label: 'Country Code'
type: 'text'
}
dimension country_name {
label: 'Country Name'
type: 'text'
}
dimension continent_name {
label: 'Continent Name'
type: 'text'
}
}
```
---
## AML Relationship
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Relationship](/docs/relationships)
- [Create a 4.0 Relationship](/docs/relationships#creating-relationship)
:::
## Relationship Syntax Definition
In AML, you can define relationships between related data models in two ways: directly inside your dataset file for quick, one-off connections, or in a separate relationship file when you want to reuse the same relationship across multiple datasets.
Every relationship needs a few key pieces of information:
- **Relationship type**: `many_to_one` or `one_to_one`
- **Join fields**: The dimensions you're connecting between the two models
- **Active status**: Whether the relationship should be enabled in your dataset
- **Filter direction** (optional): Whether filters can flow in one or both directions (see [Controlling filter and grouping paths](/docs/joins/filter-direction))
- **Nullability** (optional): Whether the joining column can contain NULL or unmatched values, which decides if Holistics generates a LEFT JOIN or an INNER JOIN (see [How Holistics handles joins](/docs/joins/how-joins-work#nullable-relationships))
- **RLP propagation** (optional): How row-level permission filters flow through the relationship, independently of the filter direction (see [Row-level permission and filter direction](/docs/joins/filter-direction#rlp-propagation))
## Defining Relationships Inside the Dataset File
When you define relationships directly in your dataset file, you have two syntax options: full-form and short-form. Let's look at both using an example that connects cities to their countries.
### Full-form Syntax
The full-form syntax gives you explicit control over each part of the relationship. Here's how it looks:
```aml title="ecommerce_test.dataset.aml"
Dataset ecommerce_test {
label: 'Ecommerce Test'
data_source_name: 'demodb'
models: [
ecommerce_cities,
ecommerce_countries
]
//highlight-start
relationships: [
RelationshipConfig {
rel: Relationship {
type: 'many_to_one'
from: r(ecommerce_cities.country_code)
to: r(ecommerce_countries.code)
}
active: true
filter_direction: 'one_way' | 'two_way'
nullable: true | false
rlp_propagation: 'inherit' | 'one_way' | 'two_way'
}
]
//highlight-end
}
```
Let's break down what each part does:
**The `Relationship {}` block** defines how two models connect to each other. It specifies which fields link together (in this case, `country_code` from cities to `code` in countries):
**The `RelationshipConfig {}` wrapper** controls the relationship's behavior within your dataset. Think of it as the "on/off switch" and other settings for the relationship:
```aml
RelationshipConfig {
rel: Relationship {} // Which relationship to configure
active: true|false // Should it be enabled?
filter_direction: 'one_way'|'two_way' // Optional: control filter flow direction
nullable: true|false // Optional: can the joining keys be NULL or unmatched?
rlp_propagation: 'inherit'|'one_way'|'two_way' // Optional: how row-level permission filters flow
}
```
When you set `active: true`, you're telling Holistics to enable this relationship in the `ecommerce_test` dataset. Here's what the final result looks like in the UI:
### Short-form Syntax
If you prefer a more compact approach, you can use the short-form syntax:
```aml title="ecommerce_test.dataset.aml"
Dataset ecommerce_test {
label: 'Ecommerce Test'
data_source_name: 'demodb'
models: [
ecommerce_cities,
ecommerce_countries
]
relationships: [
//highlight-next-line
relationship(ecommerce_cities.country_code > ecommerce_countries.code, true)
]
}
```
The short-form syntax uses operators to specify the relationship type:
- **`>`** for many-to-one relationships (e.g., many cities belong to one country)
- **`-`** for one-to-one relationships (e.g., one user has one profile)
The second parameter (`true` or `false`) sets whether the relationship is active. You can also add an optional third parameter for `filter_direction`:
```aml
relationship(ecommerce_cities.country_code > ecommerce_countries.code, true, 'one_way')
```
This compact syntax accomplishes the same thing as the full-form example above, just with less typing.
Finally, if you have verified that the joining column never contains NULL or unmatched values, you can add the named `nullable` parameter. This lets Holistics generate a faster INNER JOIN for the relationship instead of the default LEFT JOIN:
```aml
relationship(ecommerce_cities.country_code > ecommerce_countries.code, true, nullable=false)
```
:::caution
Only use `nullable=false` when referential integrity actually holds in your data. If NULL or unmatched keys exist, the INNER JOIN silently drops those rows and your totals will shrink. See [How Holistics handles joins](/docs/joins/how-joins-work#nullable-relationships) for the full behavior and a verification query.
:::
There is also a named `rlp_propagation` parameter that controls how [row-level permission](/docs/access-control/row-level-permission) filters flow through the relationship, separately from `filter_direction`:
```aml
relationship(fct_sales.country_id > dim_countries.id, true, rlp_propagation='two_way')
```
By default (`'inherit'`), permission filters follow the relationship's filter direction. Setting `'two_way'` lets them propagate in both directions even on a `one_way` relationship, which is the usual fix when row-level permission rules cannot reach a dimension behind a one-way path. See [Row-level permission and filter direction](/docs/joins/filter-direction#rlp-propagation) for the full scenario.
## Defining Reusable Relationships
While defining relationships directly in your dataset works great for one-off cases, it has a limitation: you can't reuse that relationship definition in other datasets. If you find yourself connecting the same models across multiple datasets, you'll end up duplicating code.
The solution? Define your relationship once in a separate relationship file, then reference it wherever you need it.
### Create a Relationship File
First, create a relationship in a file named `relationships.aml`:
```aml title="relationships.aml"
Relationship order_items_products {
type: 'many_to_one'
from: r(ecommerce_order_items.product_id)
to: r(ecommerce_products.id)
}
```
### Reference the Relationship in Your Dataset
Now you can use this relationship in any dataset by referencing its name:
```aml title="ecommerce_orders.dataset.aml"
Dataset ecommerce_orders {
data_source_name: 'demodb'
models: [
ecommerce_order_items,
ecommerce_products
]
relationships: [
//highlight-next-line
relationship(order_items_products, true)
]
}
```
:::tip Note
When you hover over the relationship name in code mode, Holistics shows you a tooltip with the full relationship definition. No need to open the relationship file to look it up.
:::
## Parameter Reference
### Relationship
| Parameter name | Description |
| -------------- | ------------------------------------------------------------------------------ |
| type | The relationship type: `many_to_one`, `one_to_one` |
| from | The source dimension (which field you're joining from) |
| to | The target dimension (which field you're joining to) |
### Relationship Config
| Parameter name | Description |
| ---------------- | -------------------------------------------------------------------- |
| rel | The relationship this configuration applies to |
| active | Whether this relationship should be active in the dataset (true/false) |
| filter_direction | Controls which direction filters and groupings can flow: `'one_way'` or `'two_way'` (defaults to `'two_way'`). See [Configuring fact-dimension compatibility](/docs/joins/filter-direction) for details. |
| nullable | Whether the joining column on the "many" side can contain NULL or unmatched values (defaults to `true`, which generates LEFT JOIN). Set to `false` to assert referential integrity, so Holistics generates an INNER JOIN instead. See [How Holistics handles joins](/docs/joins/how-joins-work#nullable-relationships). |
| rlp_propagation | How [row-level permission](/docs/access-control/row-level-permission) filters flow through the relationship: `'inherit'`, `'one_way'`, or `'two_way'` (defaults to `'inherit'`, which follows the relationship's `filter_direction`). See [Row-level permission and filter direction](/docs/joins/filter-direction#rlp-propagation). |
---
## AML String Interpolation
## Introduction
String interpolation is a method of embedding variable values directly into strings.
AML supports string interpolation for:
- Normal string
- Multi-line string
- Heredoc content
## Examples
```tsx
const name_1 = 'John'
const name_2 = 'Alice'
'This is ${name_1}' // 'This is John'
"This is ${name_2}" // 'This is Alice'
'''
This
is
${name_1}
'''
/*
This
is
John
*/
// String interpolation in heredoc
const model = 'ecommerce.users'
@sql select * from ${model} ;; // select * from ecommerce.users
// use string interpolation with function and if else expression
Func greet(name: 'John' | 'Alice') {
"Hello, ${if (name == 'John') { 'sir' } else { 'madam'} }!"
}
```
---
## AML Tab Layout
:::tip Knowledge Checkpoint
A grasp of this concept will help you understand this documentation better: [Dashboard Tabs](/docs/dashboards/tabs)
:::
`TabLayout` is the value for the `view` parameter in a `Dashboard` when you want to split the dashboard into named tabs. Each tab holds a `CanvasLayout` (pixel-positioned).
```aml
Dashboard my_dashboard {
// ... blocks ...
view: TabLayout {
tab detail: CanvasLayout {
label: 'Detail'
width: 1080
height: 600
block v2 { position: pos(0, 0, 1080, 600) }
}
}
}
```
## Parameters
Parameter | Description
--- | ---
`label` | Optional display name for the tab container itself.
`tab ` | A named tab. Each tab takes a `CanvasLayout` as its body. Tab names must be unique within the layout.
## Tab body types
{/* ### LinearLayout
`LinearLayout` stacks blocks in a single vertical column — no pixel positioning needed. It's the simplest layout and works well for summary views or narrow content.
Parameter | Description
--- | ---
`label` | Optional display name shown as the tab heading.
`block ` | References a block declared in the dashboard. List blocks in the order they should appear top-to-bottom.
```aml
tab overview: LinearLayout {
label: 'Overview'
block f1
block v1
block v2
}
```
*/}
### CanvasLayout
`CanvasLayout` gives you pixel-precise control over block position and size. See [AML CanvasLayout](/reference/aml/canvas-layout) for the full parameter reference including `width`, `height`, `grid_size`, `mobile`, and block positioning with `pos()`.
```aml
tab detail: CanvasLayout {
label: 'Detail'
width: 1080
height: 800
block v1 { position: pos(0, 0, 540, 400) }
block v2 { position: pos(540, 0, 540, 400) }
}
```
## Full example
A two-tab dashboard with a shared filter block:
```aml
Dashboard sales_overview {
block f1: FilterBlock {
label: 'Date range'
type: 'date'
}
block v1: VizBlock {
label: 'Revenue by month'
viz: LineChart { dataset: ecommerce }
}
block v2: VizBlock {
label: 'Orders by region'
viz: BarChart { dataset: ecommerce }
}
{/* block t1: TextBlock {
content: @md ## Notes;;
} */}
view: TabLayout {
tab charts: CanvasLayout {
label: 'Charts'
width: 1080
height: 500
block f1 { position: pos(0, 0, 300, 60) }
block v1 { position: pos(0, 70, 540, 400) }
block v2 { position: pos(540, 70, 540, 400) }
}
{/* tab notes: LinearLayout {
label: 'Notes'
block t1
} */}
}
}
```
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax including blocks, interactions, and settings
- [AML Canvas Layout](/reference/aml/canvas-layout): a single free-form canvas page with pixel-based positioning
- [AML HTML Layout](/reference/aml/html-layout): HTML and CSS layout for custom compositions, slides, and paginated reports
---
## AML Table Model
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Table Model](/docs/table-models)
:::
## Introduction
[Table Model](/docs/table-models) defines a data model that sits on top of a SQL table.
## Parameter definition
Parameter name | Description
-------------- | ------------
type | Model type: `'table'` for Table Model, `'query'` for Query Model
label | User-friendly name for the model that appears in the Dataset exploration UI
description | Description of the model. Support Markdown.
data_source_name | The database on which Holistics will execute the SQL generated from this model
owner | Define who should be in charge of managing the current model
table_name | Path to the table underlying this model, in the form: `'"schema_name"."table_name"'`
dimension | Define a dimension.
measure | Define a measure.
## Code Example
```aml
Model users {
type: 'table'
label: "Users"
data_source_name: 'your_datasource_name'
table_name: 'ecommerce.users' // underlying table of the model
dimension id {
label: 'ID'
type: 'number'
primary_key: true // recommended for query optimization and AQL features
hidden: false // optional
definition: @sql {{#SOURCE.id}};; // optional
}
dimension email {
label: 'Email'
type: 'text'
// without "definition", it automatically uses the same column name as the dimension name ('email')
}
measure user_count {
type: 'number'
label: 'Count Users'
definition: @aql count(users.id);;
}
}
```
---
## AML TextBlock
TextBlock displays static or dynamic text content inside a dashboard. It accepts Markdown, HTML, or a combination of both.
## Syntax
```aml
block : TextBlock {
content: @md ... ;; // or @html ... ;;
theme: BlockTheme { ... } // optional
}
```
## Parameters
Parameter | Description
--- | ---
`content` | The block's content. Use `@md ... ;;` for Markdown or `@html ... ;;` for HTML.
`theme` | Optional [`BlockTheme`](/reference/aml/theme#blocktheme) override for visual styling.
## Examples
### Markdown content
```aml
block header: TextBlock {
content: @md
# Sales Report
Data refreshes every 6 hours.
;;
}
```
### HTML content
```aml
block banner: TextBlock {
content: @html
Welcome to the Dashboard
;;
}
```
### With theme override
```aml
block hero: TextBlock {
theme: BlockTheme {
background { bg_color: 'transparent' }
label { font_color: '#ffffff' }
}
content: @md # Welcome ;;
}
```
For dynamic, data-driven text content that updates with your data, see [Dynamic Content Blocks](/docs/charts/dynamic-content-block).
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax
- [AML Theme](/reference/aml/theme): `BlockTheme` parameter reference
---
## AML Theme and Colors
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard Themes](/docs/admin/dashboard-themes)
- [Color Palettes](/docs/admin/dashboard-themes/color-palettes)
:::
Dashboard themes in Holistics are defined using three AML objects: `PageTheme`, `BlockTheme`, and `VizTheme`. These can be declared inline in a dashboard file or in a shared file (e.g. `themes.aml`) for reuse across dashboards. Each object maps to a level in the dashboard visual hierarchy:
```aml
// Reusable theme declared in themes.aml
PageTheme my_theme {
color {
data: palette_name
}
canvas { ... }
block { ... }
viz { ... }
}
```
See [Dashboard Themes](/docs/admin/dashboard-themes) for how-to guides on applying and creating themes.
## PageTheme
`PageTheme` is the top-level theme object. It controls the full visual hierarchy of a dashboard: page background, canvas area, block styling, and visualization styling.
Parameter name | Description
-------------- | -----------
`color` | Default [color palette](/docs/admin/dashboard-themes/color-palettes) for all charts in the dashboard
`background` | Background of the outer page viewport
`canvas` | Styling for the main canvas area
`block` | Default styling for all blocks
`viz` | Default styling for all visualizations
`custom_css` | Custom CSS injected into the dashboard
### color
Sets the default color palette for all charts in the dashboard.
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `data` | The color palette to use for chart data series | A [`ColorPalette`](#colorpalette) identifier defined in your project |
### background
Controls the outermost page container (the viewport surrounding the canvas).
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `bg_color` | Background color | Any valid [CSS ``](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) e.g. `"#ffffff"`, `"rgb(255 0 153)"` |
| `bg_image` | Background image | Any valid [CSS ``](https://developer.mozilla.org/en-US/docs/Web/CSS/image) e.g. `"https://..."`, `"linear-gradient(blue, red)"` |
| `bg_repeat` | How the background image repeats | `false`, `true`, `"x"`, `"y"`, `"space"`, `"round"` |
| `bg_size` | How the background image is sized | `"cover"`, `"contain"` |
### canvas
Controls the primary reporting area that contains all dashboard blocks.
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `border.*` | Canvas border | See [BorderTheme](#bordertheme) |
| `background.*` | Canvas background | See [BgTheme](#bgtheme) |
| `shadow` | Canvas shadow effect | `"none"`, `"sm"`, `"md"`, `"lg"` |
| `opacity` | Canvas opacity | Number between `0` and `1` |
### block
Default styling applied to all blocks in the dashboard. Can be overridden per block using `BlockTheme`.
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `label.*` | Font styling for block titles | See [FontTheme](#fonttheme) |
| `text.*` | Font styling for block text content | See [FontTheme](#fonttheme) |
| `border.*` | Block border | See [BorderTheme](#bordertheme) |
| `background.*` | Block background | See [BgTheme](#bgtheme) |
| `padding` | Internal block padding | Number, String, or [DetailedSpacing](#detailedspacing) |
| `shadow` | Block shadow effect | `"none"`, `"sm"`, `"md"`, `"lg"` |
| `opacity` | Block opacity | Number between `0` and `1` |
### viz
Default styling for data visualizations. Currently supports table-based visualizations ([Data Table](/docs/charts/table), [Pivot Table](/docs/charts/pivot-table), [Metric Sheet](/docs/charts/metric-sheets)) and [KPI Metric](/docs/charts/metric-kpi).
#### viz.table
**Note:** Table styling has an override hierarchy: `general` styles are overridden by `header`, then `sub_header`, then `sub_title`.
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `general.bg_color` | Table body background color | Any valid color value e.g. `"#ffffff"` |
| `general.hover_color` | Row hover background color | Any valid color value |
| `general.banding_color` | Alternating row color | Any valid color value |
| `general.font_size` | Font size for all table text | Number or String e.g. `12`, `"12px"` |
| `general.font_color` | Font color for all table text | Any valid color value |
| `general.font_family` | Font family for all table text | Any valid font family name |
| `general.border_color` | Table outer border color | Any valid color value |
| `general.border_width` | Table border and grid line thickness | Number or String |
| `general.grid_color` | Table grid line color | Any valid color value |
| `header.bg_color` | Header background color | Any valid color value |
| `header.font_size` | Header font size | Number or String |
| `header.font_color` | Header font color | Any valid color value |
| `header.font_weight` | Header font weight | `"light"`, `"normal"`, `"medium"`, `"semibold"`, `"bold"`, `"extrabold"` |
| `sub_header.bg_color` | Pivot Table sub-header background color | Any valid color value |
| `sub_header.font_size` | Pivot Table sub-header font size | Number or String |
| `sub_header.font_color` | Pivot Table sub-header font color | Any valid color value |
| `sub_header.font_weight` | Pivot Table sub-header font weight | `"light"`, `"normal"`, `"medium"`, `"semibold"`, `"bold"`, `"extrabold"` |
| `sub_title.font_size` | Metric Sheet subtitle font size | Number or String |
| `sub_title.font_color` | Metric Sheet subtitle font color | Any valid color value |
| `sub_title.font_weight` | Metric Sheet subtitle font weight | `"light"`, `"normal"`, `"medium"`, `"semibold"`, `"bold"`, `"extrabold"` |
| `sparkline.line.color` | Metric Sheet line color of the sparkline | Any valid color value |
| `sparkline.line.width` | Metric Sheet line width of the sparkline | Number or String |
| `sparkline.column.color` | Metric Sheet column color of the sparkline | Any valid color value |
| `sparkline.column.width` | Metric Sheet column width of the sparkline | Number or String |
#### viz.metric_kpi
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `alignment` | Aligns all KPI Metric elements inside the visualization | `"left"`, `"right"`, `"center"` |
| `label.*` | Font styling for the KPI label | See [FontTheme](#fonttheme) |
| `value.*` | Font styling for the KPI value | See [FontTheme](#fonttheme) |
| `progress.text.*` | Font styling for the progress caption | See [FontTheme](#fonttheme) |
| `progress.indicator.*` | Background of the filled progress bar | See [BgTheme](#bgtheme) |
| `progress.track.*` | Background of the progress bar track | See [BgTheme](#bgtheme) |
| `trend.positive.text.*` | Font styling for positive trend text | See [FontTheme](#fonttheme) |
| `trend.positive.background.*` | Background of positive trend badge | See [BgTheme](#bgtheme) |
| `trend.negative.text.*` | Font styling for negative trend text | See [FontTheme](#fonttheme) |
| `trend.negative.background.*` | Background of negative trend badge | See [BgTheme](#bgtheme) |
| `trend.neutral.text.*` | Font styling for neutral trend text | See [FontTheme](#fonttheme) |
| `trend.neutral.background.*` | Background of neutral trend badge | See [BgTheme](#bgtheme) |
### custom_css
Injects custom CSS into the dashboard. Accepts any valid CSS code.
```aml
PageTheme my_theme {
custom_css: @css
.my-class { color: red; }
;;
}
```
See [Custom CSS](/docs/admin/dashboard-themes/custom-css) for details and examples.
---
## BlockTheme
`BlockTheme` overrides the block styling from `PageTheme` for a specific block. It accepts the same parameters as the [`block` section of `PageTheme`](#block).
```aml
Dashboard my_dashboard {
block text_block: TextBlock {
theme: BlockTheme {
background {
bg_color: 'transparent'
}
}
}
}
```
---
## VizTheme
Use `theme { }` inside any `viz` property to override its styling for that block. Accepts the same parameters as the [`viz` section of `PageTheme`](#viz).
```aml
Dashboard my_dashboard {
block viz_block: VizBlock {
viz: PivotTable {
theme {
table {
general {
bg_color: 'white'
}
}
}
}
}
}
```
```aml
Dashboard my_dashboard {
block kpi_block: VizBlock {
viz: MetricKpi {
theme {
metric_kpi {
alignment: "center"
value {
font_color: "#1357A0"
font_weight: "bold"
}
trend {
positive {
background { bg_color: "#DCFCE7" }
}
negative {
background { bg_color: "#FEE2E2" }
}
}
}
}
}
}
}
```
---
## ColorPalette
`ColorPalette` defines a named set of colors that can be applied to chart data series. Palettes are declared in `.palette.aml` files and referenced by name in the [`color` parameter of `PageTheme`](#color) or project settings.
| Parameter | Description | Accepted values |
| --- | --- | --- |
| `title` | Display name shown in the palette picker | String |
| `categorical.colors` | Ordered list of colors for chart data series | Array of CSS color strings (hex, rgb, oklch, etc.) |
```aml
ColorPalette my_palette {
title: "My Palette"
categorical {
colors: [
"#FF5733",
"#33FF57",
"#3357FF"
]
}
}
```
See [Color Palettes](/docs/admin/dashboard-themes/color-palettes) for a full guide on creating and applying palettes.
---
## Helper types
### FontTheme
Used for text styling in blocks and visualizations.
| Property | Description | Accepted values |
| --- | --- | --- |
| `font_family` | Font family. [Learn more](/docs/admin/dashboard-themes/use-custom-fonts) | String |
| `font_source` | Source URL for a custom web font (used to load the font if not already available) | String |
| `font_size` | Font size (e.g. `14`, `"14px"`, `"1rem"`) | Number or String |
| `font_color` | Font color | String |
| `font_weight` | Font weight | `"light"`, `"normal"`, `"medium"`, `"semibold"`, `"bold"`, `"extrabold"` |
| `font_style` | Font style | `"normal"`, `"italic"` |
| `letter_spacing` | Letter spacing | Number or String |
| `text_decoration` | Text decoration | `"underline"`, `"overline"`, `"line-through"` |
| `text_transform` | Text transform | `"uppercase"`, `"lowercase"`, `"capitalize"` |
### BgTheme
Used for background styling in `canvas`, `block`, and KPI Metric progress bars and trend badges.
| Property | Description | Accepted values |
| --- | --- | --- |
| `bg_color` | Background color | String |
| `bg_image` | Background image | String |
| `bg_repeat` | How the background image repeats | Boolean, `"x"`, `"y"`, `"space"`, `"round"` |
| `bg_size` | How the background image is sized | `"cover"`, `"contain"` |
### BorderTheme
Used for border styling in `canvas` and `block`.
| Property | Description | Accepted values |
| --- | --- | --- |
| `border_width` | Border width | Number, String, or [DetailedSpacing](#detailedspacing) |
| `border_radius` | Corner roundness | Number, String, or [DetailedRadius](#detailedradius) |
| `border_color` | Border color | Any valid [CSS ``](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) |
| `border_style` | Border style | `"none"`, `"solid"`, `"dotted"`, `"dashed"`, `"inset"`, `"outset"`, `"ridge"`, `"groove"`, `"double"` |
### DetailedSpacing
Used for specifying different spacing values for each side of an element (e.g. `border_width`, `padding`).
| Property | Description | Type |
| --- | --- | --- |
| `top` | Spacing for the top side | Number or String |
| `left` | Spacing for the left side | Number or String |
| `bottom` | Spacing for the bottom side | Number or String |
| `right` | Spacing for the right side | Number or String |
```aml
padding: {
top: 10,
left: "5px",
bottom: 10,
right: "5px"
}
```
### DetailedRadius
Used for specifying different border-radius values for each corner of an element (e.g. `border_radius`).
| Property | Description | Type |
| --- | --- | --- |
| `top_left` | Radius for the top-left corner | Number or String |
| `top_right` | Radius for the top-right corner | Number or String |
| `bottom_left` | Radius for the bottom-left corner | Number or String |
| `bottom_right` | Radius for the bottom-right corner | Number or String |
```aml
border_radius: {
top_left: 5,
top_right: "10px",
bottom_left: 5,
bottom_right: "10px"
}
```
---
## AML Types
## Introduction
AML includes a powerful type system that enables both a real-time feedback to analysts to help them avoid errors early, and as the basis for sophisticated reusability features such as [constant declaration](/reference/aml/constant), [functions](/reference/aml/func), and [extend](/reference/aml/extend).
## Basic types
### Number
- All kind of numbers (including integer and fractional)
```tsx
Number pi = 3.14159265
Number b = 2
Number c = -10
```
### Int
- Represents numbers without decimal
```tsx
Int a = 1
Int b = 2
Int c = -100
```
### Boolean
```tsx
Boolean is_truthy = true
Boolean is_falsy = false
```
### String
A string is a sequence of characters used to represent text.
AML supports 2 kind of strings:
* normal string (single/double quotes)
* multi-line string (triple quotes)
```tsx
const model_name = 'ecommerce.users' // single-quote
const dataset_name = "ecommerce" // double-quote
// Multi-line string
This is
a note
that expands
on multiple
lines
'''
// "This is\na note\nthat expands\non multiple\nlines"
```
### List
- Stores a collection of elements, typically of the same type
- Syntax: `List[T]` (where `T` is element’s type)
```tsx
Type ListInt = List[Int]
Type ListString = List[String]
ListInt numbers = [1, 2, 3]
ListString names = ['Alex', 'John', 'Bob']
// access elements inside a list
numbers(0) // 1
numbers(1) // 2
numbers(2) // 3
names(1) // 'John'
```
### Dictionary
- Stores a collection of key-value pairs
- Each key-value pair maps the key to its associated value
- Syntax: `Dict[K, V]` where `K` is a key’s type, `V` is a value’s type
```tsx
Type NumberDict = Dict[String, Number]
NumberDict employee_ages = {
alex: 23
bob: 30
john: 29
}
Type StringDict = Dict[String, String]
StringDict employee_addresses = {
alex: '505 SW 1st Ave'
john: ' 4070 Red Arrow Hwy'
bob: '851 St Georges Ave'
}
// access dictionary entries
employee_ages('alex') // 23
employee_addresses('bob') // '851 St Georges Ave'
```
### Any
- is a type that can hold any value, effectively opting out of type checking
---
## AML user attributes and variables
## Introduction
AML expressions can use runtime context from the current user and the current Git environment. This is useful when the same AML code needs to behave differently for different users, teams, branches, or production environments.
You can use these values anywhere AML accepts an expression, such as dynamic data source names, dynamic schemas, or AI custom context.
## User attributes
Use `H.current_user.` to access any [custom user attribute](/docs/admin/user-attributes) of the current user. For example, `H.current_user.data_source` returns the current user's `data_source` attribute, which you can use to route each user to the correct database.
Before referencing a custom user attribute in AML, define it in [User Attributes Management](/docs/admin/user-attributes#add-new-user-attributes). The attribute name in AML must match the user attribute name exactly.
```aml
Dataset sales {
// highlight-next-line
data_source_name: H.current_user.data_source
models: [orders]
}
```
### Use user attributes in conditional logic
User attributes also work well with [if-else expressions](/reference/aml/if-else). In this example, analysts and marketers receive different AI context based on their `team` attribute.
```aml
const ai_context = @md
${
if (H.current_user.team == 'Business Analyst') {
'Focus on product performance, margin, and inventory analysis.'
} else if (H.current_user.team == 'Growth & Marketing') {
'Focus on customer acquisition, retention, and campaign performance.'
} else {
'Answer using the standard company definitions in the semantic layer.'
}
}
;;
```
## System user attributes
[System user attributes](/docs/admin/user-attributes#system-user-attributes) are provided by Holistics, prefixed with `h_`, and cannot be modified by users.
| Attribute | Description |
|---|---|
| `H.current_user.h_email` | The email of the current user account. |
| `H.current_user.h_role` | The role of the current user account, such as `admin`, `analyst`, `explorer`, or `viewer`. |
In this example, admins connect to the production database while other roles are routed to a restricted data source:
```aml
Dataset sales {
// highlight-start
data_source_name:
if (H.current_user.h_role == 'admin') {
'production_data_source'
} else {
'restricted_data_source'
}
// highlight-end
models: [orders]
}
```
## Holistics built-in variables
Holistics also provides built-in variables for the current Git and deployment context. These are useful when you want different behavior in development and production.
| Variable | Description |
|---|---|
| `H.git.current_branch` | Returns the name of the current Git branch. |
| `H.git.is_production` | Returns `true` when in the Reporting tab or in the Development workspace with production mode enabled. |
For example, this setup uses each user's `data_source` attribute in production, but always uses a development database while modeling:
```aml
Dataset sales {
// highlight-start
data_source_name:
if (H.git.is_production) {
H.current_user.data_source
} else {
'development_data_source'
}
// highlight-end
models: [orders]
}
```
## Common use cases
These variables are most commonly used when your AML project needs to adapt at runtime:
- [Dynamic data sources](/docs/development/dynamic-data-source): route the same dataset or model to different databases.
- [Dynamic schemas](/docs/development/dynamic-schema): use different database schemas for different environments or users.
- [Custom context for Holistics AI](/docs/ai/context/custom-context): tailor AI instructions for different teams or workflows.
- [Row-level permissions as code](/docs/access-control/row-level-permission-as-code): define data access rules based on user attributes.
---
## AML VizBlock
VizBlock wraps a visualization (chart, table, KPI) with a label and optional styling. The `viz` parameter specifies the chart type and its configuration.
## Syntax
```aml
block : VizBlock {
label: 'Block Title'
viz: {
dataset:
// chart-specific parameters
}
theme: BlockTheme { ... } // optional
}
```
## Parameters
Parameter | Description
--- | ---
`label` | Display name shown as the block title.
`viz` | The visualization object. See [chart types](#chart-types) below.
`theme` | Optional [`BlockTheme`](/reference/aml/theme#blocktheme) override for visual styling.
## Chart types
The `viz` parameter accepts any of the following chart types:
Chart type | Description
--- | ---
`BarChart` | Vertical or horizontal bar chart
`LineChart` | Line chart
`AreaChart` | Area chart
`CombinationChart` | Mixed bar + line chart
`PieChart` | Pie or donut chart
`ScatterChart` | Scatter plot
`BubbleChart` | Bubble chart
`Funnel` | Funnel chart
`Gauge` | Gauge chart
`Radar` | Radar/spider chart
`Pyramid` | Pyramid chart
`WordCloud` | Word cloud
`SingleValue` | Single metric or KPI card
`DataTable` | Data table
`PivotTable` | Pivot table
`MetricSheet` | Metric sheet
## Example
```aml
block revenue_chart: VizBlock {
label: 'Revenue Over Time'
viz: CombinationChart {
dataset: ecommerce
settings {
row_limit: 5000
legend_label: 'top'
}
}
}
```
```aml
block order_count: VizBlock {
label: 'Total Orders'
viz: SingleValue {
dataset: ecommerce
series {
field {
ref: r(orders.id)
aggregation: 'count'
}
}
}
}
```
For per-chart-type parameters, the AML editor shows accepted values on hover. You can also apply a [`VizTheme`](/reference/aml/theme#viztheme) inside the `viz` object to override table styling.
## See also
- [AML Dashboard](/reference/aml/dashboard): full dashboard syntax
- [AML Theme](/reference/aml/theme): `BlockTheme` and `VizTheme` reference
---
## Aggregate Functions (Aggregators)
Aggregate Functions are functions that group values of multiple rows into a single summary value. They are equivalent to aggregate functions that SQL supports (SUM, COUNT, AVG, MAX, MIN,...). For more information about how to use them, please refer to their [concept](/as-code/aql/learn/grouping) page.
---
### count
```aml
count(field)
count(table, field)
```
```aml title="Examples"
count(orders.id)
count(orders, orders.id)
// with pipe
orders | count(orders.id)
```
**Description**
Counts the total number of items in a group, not including NULL values.
**Return type**
Whole number
---
### count_if
```aml
count_if(truefalse_field)
count_if(table, condition)
```
```aml title="Examples"
count_if(orders.country == 'Vietnam')
// with pipe
orders | count_if(orders.country == 'Vietnam')
```
**Description**
Counts total rows from one table that satisfy the given condition.
**Return type**
Whole number
---
### count_distinct
```aml
count_distinct(field)
count_distinct(table, field)
```
```aml title="Examples"
count_distinct(orders.id)
count_distinct(orders, orders.id)
// with pipe
orders | count_distinct(orders.id)
```
**Description**
Counts the total number of distinct items in a group, not including NULL values.
**Return type**
Whole number
---
### approx_count_distinct (alias: approx_countd)
```aml
approx_count_distinct(field)
approx_count_distinct(table, field)
```
```aml title="Examples"
approx_count_distinct(orders.user_id)
approx_count_distinct(orders, orders.user_id)
// with pipe
orders | approx_count_distinct(orders.user_id)
// using alias
approx_countd(orders.user_id)
```
**Description**
Counts the approximate number of distinct items in a group, not including NULL values. This function uses HyperLogLog algorithm to provide an approximate count that is much faster and uses less memory than exact count_distinct for large datasets.
**Return type**
Whole number
**Supported databases**
- Snowflake
- BigQuery
- Databricks
- MotherDuck
- Presto/Athena
**Notes**
- The approximation error is typically within 2-3% of the actual value
- For running totals with `approx_count_distinct`, MotherDuck is not supported due to missing DataSketch extension
- This function is particularly useful for large datasets where exact counts are expensive
---
### average (alias: avg)
```aml
average(field)
average(table, field)
```
```aml
average(orders.value)
average(orders, orders.value)
// with pipe
orders | average(orders.value)
```
**Description**
Averages the values of items in a group, not including NULL values.
**Return type**
Number
---
### min
```aml
min(field)
min(table, field)
```
```aml title="Examples"
min(orders.quantity)
min(orders, orders.quantity)
// with pipe
orders | min(orders.quantity)
```
**Description**
Return the item in the group with the smallest value, not including NULL values.
**Return type**
Vary
---
### max
```aml
max(field)
max(table, field)
```
```aml
max(order_item.quantity)
max(orders, order_item.quantity)
// with pipe
orders | max(order_item.quantity)
```
**Description**
Returns the item in the group with the largest value, not including NULL values.
**Return type**
Varies
---
### sum
```aml
sum(field)
sum(table, field)
```
```aml
sum(order_item.quantity)
sum(order_items, order_item.quantity)
// with pipe
order_items | sum(order_item.quantity)
```
**Description**
Sums the value in the group, not including NULL values.
**Return type**
Number
---
### median
```aml
median(field)
median(table, field)
```
```aml title="Examples"
median(orders.quantity)
median(orders, orders.quantity)
// with pipe
orders | median(orders.quantity)
```
**Description**
Computes the median of the values in the group, not including NULL values.
**Return type**
Number
---
### stdev
```aml
stdev(field)
stdev(table, field)
```
```aml title="Examples"
stdev(orders.id)
stdev(orders, orders.id)
// with pipe
orders | stdev(orders.id)
```
**Description**
Computes the standard deviation (sample) of the values in the group, not including NULL values.
**Return type**
Number
---
### stdevp
```aml
stdevp(field)
stdevp(table, field)
```
```aml title="Examples"
stdevp(orders.id)
stdevp(orders, orders.id)
// with pipe
orders | stdevp(orders.id)
```
**Description**
Computes the standard deviation (population) of the values in the group, not including NULL values.
**Return type**
Number
---
### var
```aml
var(field)
var(table, field)
```
```aml title="Examples"
var(orders.id)
var(orders, orders.id)
// with pipe
orders | var(orders.id)
```
**Description**
Returns the variance (sample) of the values in the group, not including NULL values.
**Return type**
Number
---
### varp
```aml
varp(field)
varp(table, field)
```
```aml title="Examples"
varp(orders.id)
varp(orders, orders.id)
// with pipe
orders | varp(orders.id)
```
**Description**
Returns the variance (population) of the values in the group, not including NULL values.
**Return type**
Number
---
### corr
```aml
corr(table, field1, field2)
```
**Description**
Returns the Pearson correlation coefficient of two number fields in the table.
**Return type**
Number
**Examples**
```aml title="Calculate the correlation between age and order value"
corr(users, users.age, orders.value)
```
---
### string_agg
```aml
string_agg(expression, sep: _sep, distinct: _distinct, order: _order)
string_agg(table, expression, sep: _sep, distinct: _distinct, order: _order)
```
**Description**
Returns a text that is the concatenation of all values of the expression.
**Return type**
Text
**Examples**
```aml title="Basic usage: Concatenate all product names"
string_agg(products.name)
```
```aml title="Concatenate product names with a separator"
string_agg(products.name, sep: ', ')
```
```aml title="Concatenate distinct product names with a separator"
string_agg(products.name, sep: ', ', distinct: true)
```
```aml title="Concatenate product names, ordered by name"
string_agg(products.name, order: 'asc')
```
```aml title="Concatenate product names, ordered by name (descending)"
string_agg(products.name, order: 'desc')
```
**Parameters**
- `expression`: A field or an AQL expression to be evaluated in each row of the table to be aggregated
- `table` (optional): The table to aggregate. Only optional when the table can be inferred from the expression
- `sep` (optional): Separator between values, default is `','`
- `distinct` (optional): If true, only distinct values are concatenated, default is `false`
- `order` (optional): Specifies the ordering of values ('asc' or 'desc'), default is not specified
**Notes**
For SQL Server, the distinct parameter is not supported.
---
### percentile_cont
```aml
percentile_cont(expression, percentile)
percentile_cont(table, expression, percentile)
```
**Description**
Returns the value at the given percentile of the sorted expression values, interpolating between adjacent values if needed.
**Return type**
Number
**Examples**
```aml title="Calculate 70th percentile of user ages"
percentile_cont(ecommerce_users.age, 0.7)
```
```aml title="Full form usage: Calculate 70th percentile of user ages"
ecommerce_users | percentile_cont(ecommerce_users.age, 0.7)
```
```aml title="Nested aggregation: Calculate 70th percentile of total value by users"
unique(ecommerce_users.id) | percentile_cont(total_value, 0.7)
```
**Parameters**
- `expression`: A field or an AQL expression to be evaluated
- `table` (optional): The table to aggregate. Only optional when the table can be inferred from the expression
- `percentile`: The percentile to compute. Must be a value between 0 and 1
**Notes**
This is not supported in the following databases:
- MySQL
- Presto/Athena
- Bigquery (it only support `percentile_disc` and window function version of `percentile_cont`)
---
### percentile_disc
```aml
percentile_disc(expression, percentile)
percentile_disc(table, expression, percentile)
```
**Description**
Returns the value at the given percentile of the sorted expression values. If the percentile falls between two values, a discrete value will be returned (the logic to select the value is database dependent).
**Return type**
Number
**Examples**
```aml title="Calculate 70th percentile of user ages"
percentile_disc(ecommerce_users.age, 0.7)
```
```aml title="Full form usage: Calculate 70th percentile of user ages"
ecommerce_users | percentile_disc(ecommerce_users.age, 0.7)
```
```aml title="Nested aggregation: Calculate 70th percentile of total orders by users"
unique(ecommerce_users.id) | percentile_disc(count(ecommerce_orders.id), 0.7)
```
**Parameters**
- `expression`: A field or an AQL expression to be evaluated
- `table` (optional): The table to aggregate. Only optional when the table can be inferred from the expression
- `percentile`: The percentile to compute. Must be a value between 0 and 1
---
### min_by
```aml
min_by(table, value, by)
```
**Description**
Returns the value of `value` from the row where `by` is minimum.
**Return type**
Varies
**Examples**
```aml title="Get the name of the customer with the lowest order value"
min_by(orders, orders.customer_name, orders.value)
```
**Notes**
This function is not supported in the following databases:
- MySQL
- PostgreSQL
- Redshift
- SQL Server
---
### max_by
```aml
max_by(table, value, by)
```
**Description**
Returns the value of `value` from the row where `by` is maximum.
**Return type**
Varies
**Examples**
```aml title="Get the name of the customer with the highest order value"
max_by(orders, orders.customer_name, orders.value)
```
**Notes**
This function is not supported in the following databases:
- MySQL
- PostgreSQL
- Redshift
- SQL Server
---
## AI Functions
:::warning Platform Availability
AI functions are **only available on Databricks and Snowflake** data platforms. These functions leverage the native AI capabilities provided by these platforms and are not supported on other database systems.
:::
Learn more about using AI functions in [Perform AI Queries](/docs/ai/run-ai-functions) docs.
### ai_complete
```jsx
ai_complete(model, prompt)
```
```jsx title="Examples"
ai_complete('databricks-meta-llama-3-3-70b-instruct', 'What is Apache Spark?') // => "Apache Spark is an open-source unified analytics engine..."
ai_complete('gpt-4', 'Explain data warehousing concepts') // => "Data warehousing is the process of collecting and managing data..."
```
**Description**
Queries an AI model with a text prompt and returns the generated response. This function enables natural language interactions with large language models directly within your queries.
:::info Model Availability and Pricing
The exact models available and their pricing depend on your database platform:
- **Databricks**: Supports models like Meta Llama, DBRX, and others. See [Databricks AI Functions pricing](https://docs.databricks.com/en/sql/language-manual/functions/ai_query#usage-and-pricing).
- **Snowflake**: Supports various models including Claude, Llama, Mistral, and GPT models through Cortex. See [Snowflake Cortex LLM Functions](https://docs.snowflake.com/en/sql-reference/functions/ai_complete-single-string) for available models and consult your Snowflake account for pricing details.
Check your platform's documentation for the most up-to-date model list and pricing information.
:::
**Return type**
Text
---
### ai_similarity
```jsx
ai_similarity(text1, text2)
```
```jsx title="Examples"
ai_similarity('Apache Spark', 'Apache Spark') // => 1.0
ai_similarity('cat', 'dog') // => 0.8
ai_similarity('database', 'spreadsheet') // => 0.6
ai_similarity('apple', 'quantum physics') // => 0.1
```
**Description**
Calculates the semantic similarity between two text strings using embedding-based comparison. Returns a value between 0 and 1, where 1 indicates identical meaning and 0 indicates no semantic similarity.
**Return type**
Number
---
### ai_classify
```jsx
ai_classify(text, category1, category2, ...categories)
```
```jsx title="Examples"
ai_classify('My password is leaked.', 'urgent', 'not urgent') // => 'urgent'
ai_classify('Thank you for your purchase', 'complaint', 'feedback', 'inquiry') // => 'feedback'
ai_classify('When will my order arrive?', 'technical', 'billing', 'shipping', 'general') // => 'shipping'
```
**Description**
Classifies the input text into one of the provided categories using AI. The function analyzes the semantic content of the text and returns the most appropriate category from the list provided.
**Return type**
Text
---
### ai_summarize
```jsx
ai_summarize(content)
```
```jsx title="Examples"
ai_summarize('Long article about climate change impacts on coastal regions...') // => "This article discusses the significant effects of climate change on coastal areas, including rising sea levels, increased storm intensity, and ecosystem disruption."
ai_summarize(product_reviews.review_text) // => "Customers praise the product's durability and ease of use but note concerns about the price point."
```
**Description**
Generates a concise summary of the provided text content using AI. This function is useful for creating executive summaries, condensing long-form content, or providing quick insights from verbose text data.
**Return type**
Text
---
## AQL Condition
## Introduction
AQL Condition is an expression that lets you apply complex criteria to your [explore](/reference/aql/explore-expression). It follows the structure of the `condition` parameter in the **[where](/reference/aql/where#definition)** function.
Here's an example of an explore with AQL metrics:
```ts
explore {
dimensions {
// your_dimensions
}
measures {
orders: total_orders | where(users.gender == 'female' or countries.name == 'Vietnam'),
revenue: revenue | where(users.gender == 'female' or countries.name == 'Vietnam') ,
users: total_users | where(users.gender == 'female' or countries.name == 'Vietnam')
}
filters {
// your_condition
}
}
```
With AQL Condition, you can streamline the explore like this:
```ts
explore {
dimensions {
// your_dimensions
}
measures {
orders: total_orders,
revenue: revenue,
users: total_users
}
filters {
//highlight-next-line
users.gender == 'female' or countries.name == 'Vietnam'
}
}
```
This approach lets you easily apply advanced filtering to all metrics, helping you get accurate insights from complex data.
For example, you will be able to do:
- Nested Filtering
- Filter multiple fields from different models simultaneously
## How to use
1. Go to the Visualization section.
2. Navigate to the Condition tab.
3. Click on "Add AQL Condition"

## Sample use cases
### Example 1: Nested Filtering
**Scenario**: You want to find a list of customers who made their first purchase in a specific category (e.g., the gaming category). From this list, you want to understand how many products these customers purchased.

How to Solve This:
1. Obtain a metric that returns the list of customers who made their first purchase in the "Gaming" category.
```tsx
// metric name: first_gaming_users
unique(users.name, orders.id, categories.name)
| select(
user_name: users.name,
order_id: orders.id,
category_name: categories.name,
_rank: rank(
order: min(orders.created_at) | of_all(categories.name),
partition: users.name
)
)
| filter(_rank == 1, category_name == 'Gaming')
| select(user_name)
```

2. Build a visualization showing the total products bought by each user, filtered to the list of users identified in step 1.

### Example 2: Filter multiple fields from different models
**Scenario**: You have a dataset that contains transaction details between buyers and sellers. There is a field called "gender" in both the buyers and sellers models. You want to simultaneously apply the filter to both gender fields to understand the transaction details between male or female buyers and sellers.

Without filter expression, you would have to apply filter for buyer_gender and seller_gender individually

Write an AQL Filter Expression to answer this question:
```tsx
buyers.buyer_gender == 'male'
and
sellers.seller_gender == 'male'
```
And if you want your users to apply filter for the Gender of both Buyers and Sellers via Dashboard Filter, you can refer to our [Dynamic Conditions](/docs/modeling/dynamic-conditions) documentation.
---
## bottom
## Definition
Find the bottom N values of a [dimension](/reference/aql/type-dimension), based on specified measures. The bottom rows are determined by the specified measures, in ascending order. Default to use 'skip' logic, which is the same as the [rank](/reference/aql/rank) function. To use 'dense' logic, use the `logic: 'dense'` parameter.
**Syntax**
```aml
bottom(n, dimension, by: measure_expr, ...)
bottom(n, dimension, by: measure_expr, ..., logic: logic)
```
```aml title="Examples"
bottom(10, users.name, by: count(orders.id)) // -> Table(users.name)
bottom(10, users.name, by: count(orders.id), by: average(users.age)) // -> Table(users.name)
bottom(10, users.name, by: count(orders.id), logic: 'dense') // -> Table(users.name)
```
**Input**
- `n` (**required**): The number of bottom values to return.
- `dimension` (**required**): A fully-qualified reference to a dimension. The output table will have one row for each bottom value of the specified dimension.
- `by` (**repeatable**, **required**): A measure that is used for ranking. The bottom rows are determined by the specified measure, in ascending order. E.g. `bottom(10, users.name, by: count(orders.id))`
- `logic` (**optional**): The logic to use for ranking. Default to **'skip'**. To use 'dense' logic, use `logic: 'dense'`. E.g. `bottom(10, users.name, by: count(orders.id), logic: 'dense')`
**Output**
A new table with one row for each bottom value of the specified dimension.
## Sample Usages
Notice that the their are 6 users in the bottom 5 because there are users with the same number of orders.
Using dense rank logic would return even more users because their rank is consecutive. E.g. The users below the 2 bottom-1 users would be ranked 2 instead of skipping to 3.
Bottom is especially useful when used with [where](/reference/aql/where) to filter measure to only include the bottom values.
You can also use it for nested aggregation, such as finding the bottom 5 users by number of orders, then finding the average age of those users. (Note: `orders.value` is a measure)
## See also
- [`top()`](/reference/aql/top)
- [`group()`](/reference/aql/group)
- [`rank()`](/reference/aql/rank)
- [Pipe operator](/reference/aql/operator#pipe)
---
## Datetime Literals
Datetime literal represents a single timestamp or a range of time. They are typically used with [datetime comparison operators](/reference/aql/operator#datetime).
## Structure
Datetime literal start with `@` token. Datetimes can be expressed in a fully supported format as `@YYYY-MM-DD HH:MM:SS`, in shorter variations like `@YYYY-MM`, or relative datetime (relative to the current real world time) like `@(last 7 days)`.
:::tip
For a full list of datetime expressions, please refer to the [Natural Time Expression](/docs/datetimes/relative-dates) document.
:::
## Examples
```aml title="Fixed date"
@2023
@2023-04
@2023-04-01
```
```aml title="Fixed range"
@2023 - 2023
@2023-04 - 2023-05
@2023-04-01 - 2023-04-30
```
```aml title="Relative datetime"
@(now)
@(today)
@(yesterday)
@(last 7 days)
@(last 3 months)
```
**Note:** For one-word values like `@now`, `@yesterday`, or `@today`, parentheses are optional.
---
## dense_rank
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window function that returns the rank of rows within a partition of a table. Tie values are assigned the same rank. The next rank in the sequence is consecutive. E.g. 1, 1, 2, 3, 3, 4, ...
To return non-consecutive rank, use [rank](/reference/aql/rank).
**Syntax**
```aml
dense_rank(order: order_expr)
dense_rank(order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
dense_rank(order: count(orders.id) | desc())
dense_rank(order: count(orders.id), order: average(users.age))
// with partition
dense_rank(order: count(orders.id), partition: orders.status)
// Axis-aware examples
dense_rank(order: 'rows') // Dense rank by row order
dense_rank(order: 'columns' | desc()) // Dense rank by column order (descending)
dense_rank(order: revenue | desc(), partition: 'rows') // Dense rank within each row
```
**Input**
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `dense_rank(order: count(orders.id) | desc())` or `dense_rank(order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `dense_rank(order: count(orders.id), partition: orders.status)` or `dense_rank(order: revenue, partition: 'rows')`
**Output**
Rank of the current row within its partition (if no partition is specified, the whole table is considered as a single partition). If two rows are tied for a rank, each tied rows receives the same rank. The next rank in the sequence is consecutive. E.g. 1, 1, 2, 3, 3, 4, ...
## Sample Usages
Using rank in a dimension definition is straight-forward. Remember that the rank is calculated against all rows in the model.
```aml
dimension ranking_by_age {
label: 'Rank By Age'
type: 'number'
hidden: false
description: ''
definition: @aql dense_rank(order: users.age | desc()) ;;
}
```
You can also use it in `filter()` to filter out the top 10 users by age:
## See also
- [`rank()`](/reference/aql/rank): same value gets same rank, but next rank skips ahead
- [`percent_rank()`](/reference/aql/percent_rank)
- [`ntile()`](/reference/aql/ntile)
- [Window Functions](/reference/aql/window-function)
---
## dimensionalize (previously exact_grains)
:::info
We have renamed `exact_grains` to `dimensionalize` to align with how the function is used in practice.
While we will continue to support `exact_grains` for backward compatibility, please use `dimensionalize` to ensure that your code is readable.
:::
## Definition
Evaluate an expression using a specific Level of Detail (LoD) context, regardless of the outer context. You can use `dimensionalize` for use cases like [dimensionalized measure](/as-code/aql/cookbook/level-of-detail#use-case-2-fixed-lod--dimensionalize) and [cohort analysis](/as-code/aql/cookbook/aql-cohort-retention).
:::caution
This is only allowed in the definition of a Dimension, and not elsewhere.
:::
**Syntax**
```aml
dimensionalize(measure, dimension, ...)
```
```aml title="Examples"
dimension order_value {
description: "Total order value of this user"
definition: @aql dimensionalize(sum(order_items.order_value), users.id) ;;
}
// with pipe
dimension order_value {
description: "Total order value of this user"
definition: @aql sum(order_items.order_value) | dimensionalize(users.id) ;;
}
```
**Input**
- `measure`: A measure that you want to evaluate against a specific LoD context.
- `dimension` (**repeatable**): A dimension that you want to use as the LoD context.
**Output**
A new dimension
---
## Sample Usages
The following example creates a dimension `customer_lifetime_value`, by dimensonalizing the measure total sales
```aml
dimension customer_lifetime_value {
...
definition: @aql sum(orders.amount) | dimensionalize(users.id) ;;
}
```
Total orders *amount* of this *user* equal to *customer lifetime value* field
We can also reuse this dimension just like other dimension to derive further analytics insight. Let's define `average_customer_lifetime_value` metric
```aml
metric average_customer_value {
...
definition: @aql average(users.customer_lifetime_value) ;;
}
```
Using the above metric to find Average Customer Lifetime Value per Continent
## See also
- [`of_all()`](/reference/aql/of_all)
- [`keep()`](/reference/aql/keep)
- [Level of Detail](/as-code/aql/learn/level-of-detail)
- [Order of Operations](/as-code/aql/order-of-operations)
---
## AQL Error Reference
This is one of three documents aimed at helping answer common questions about AQL:
1. [Troubleshooting](/as-code/reference/troubleshooting)
2. [AQL Error Reference](/reference/aql/error-reference) (this doc)
This page contains explanations for common errors from AQL to help you quickly debug and resolve issues.
## ERR-0: Unexpected internal error {#ERR-0}
**Description:** An unexpected internal error occurred in the AQL parser that wasn't anticipated by the system.
**Solution:** Contact support@holistics.io with details about what you were doing when the error occurred.
## ERR-1: Syntax error {#ERR-1}
**Description:** Your AQL query contains a syntax error that violates the grammar rules of the language.
**Common Causes:**
- Using `=` instead of `==` for equality comparison
- Using `<>` instead of `!=` for inequality comparison
- Missing quotes around string literals
- Unclosed parentheses or brackets
**Example:**
```
Syntax error.
> 1 | users.id = 4000
| ^
Expected one of the following:
'!=', '*', '+', '-', '/', '//', ';', '<', '<=', '==', '>', '>='
```
**Solution:** Check the position indicated by the arrow (^) and replace the incorrect syntax with one of the suggested alternatives.
## ERR-100: Invalid expression {#ERR-100}
**Description:** This is a general error for issues that haven't been classified with a specific error code yet.
**Solution:** Review the specific error message provided, which should give context about the problem. If you can't resolve the issue, contact support@holistics.io so we can improve our documentation.
## ERR-101: Relationship not found {#ERR-101}
**Description:** Your query is trying to use fields from unrelated models (models that have no relationships defined between them).
**Example:**
```aml
users | select(unrelated_model.id)
```
**Solution:**
1. Ensure that a relationship between the models exists in your dataset
2. Review the [Relationship documentation](/docs/relationships) to learn how to define relationships
## ERR-102: Model not found {#ERR-102}
**Description:** The model you're referencing in your query doesn't exist in the dataset.
**Common Causes:**
1. The model hasn't been added to the dataset you're using
2. You're using a cross-model field in Model Preview instead of Dataset Preview
3. There's a typo in the model name
**Solution:**
- Check if the model exists in your dataset and add it if needed
- Switch to Dataset Preview when working with cross-model fields
- Verify the spelling of model names in your query
## ERR-103: Field not found in model {#ERR-103}
**Description:** The field you're trying to access doesn't exist in the specified model.
**Common Causes:**
1. Typo in the field name
2. The field exists in a different model
3. The field hasn't been defined yet
**Solution:**
- Check for typos in field names
- Verify that you're referencing the correct model
- Make sure the field is defined in the model
## ERR-104: Fanout detected {#ERR-104}
**Description:** This error occurs when you're trying to access a "many" model from a model on the "one" side of a relationship.
**Example:**
```aml
orders | select(orders_items.quantity)
```
This also applies when accessing other models in the definition of a dimension:
```aml
Model orders {
dimension item_quantity {
// This will error
definition: @aql order_items.quantity ;;
}
}
```
**Solution:**
1. Change the source table that you're starting from (by changing the source or where the dimension is defined)
2. Apply an aggregation on the column:
- For metrics: `orders | group(orders.id) | select(sum(orders_items.quantity))`
- For dimensions:
```aml
Model orders {
dimension item_quantity {
definition: @aql dimensionalize(sum(order_items.quantity), orders.id) ;;
}
}
```
3. In some cases, both tables are on the "many" side with a "one" table in the middle. You can use [unique](/reference/aql/unique) to obtain a table with the cartesian product of the relevant fields:
```aml
// users have many wishlist_products, and many (bought) products
unique(wishlist_products.price, products.price)
| select(wishlist_products.price - products.price)
| avg() // average delta of wishlist and actual buy
```
## ERR-105: Model in aql field not found {#ERR-105}
**Description:** Same as [ERR-103](/reference/aql/error-reference#ERR-103) - the model referenced in an AQL field cannot be found.
## ERR-106: Relationship path not found {#ERR-106}
**Description:** Same as [ERR-101](/reference/aql/error-reference#ERR-101) - the relationship path between models cannot be found.
## ERR-200: Invalid function argument count {#ERR-200}
**Description:** The function was called with an incorrect number of arguments.
**Example:**
[div](/reference/aql/math-functions#div) called as `div(4)` instead of `div(4, 2)`.
**Solution:**
- Check the function documentation to see how many arguments it requires
- Make sure you're providing all required arguments
## ERR-201: Invalid argument type {#ERR-201}
**Description:** Same as [ERR-240](/reference/aql/error-reference#ERR-240) - the type of argument provided to a function is not compatible with what the function expects.
## ERR-202: Unknown identifier {#ERR-202}
**Description:** The model, field, or metric referenced in the query does not exist in the current scope. This can also be due to the same error as [ERR-102](/reference/aql/error-reference#ERR-102).
**Example:**
```aml
users | select(one: 1) | select(two) // <-- this does not exist
```
**Solution:**
- Check for typos in identifiers
- Ensure the referenced item is defined and accessible in the current scope
- Verify that all required models are included in your dataset
## ERR-203: Unsupported operator for type {#ERR-203}
**Description:** The operator being used is not supported for the data type it's being applied to.
**Example:**
```aml
// error
// This will error
users.last_name + " " + users.first_name
// while this is fine
concat(users.last_name, " ", users.first_name)
```
**Solution:**
Check the table of supported operators for the type you're using, such as [Text Operators](/reference/aql/type-text#text-operator).
## ERR-204: Type mismatch {#ERR-204}
**Description:** AQL expected a specific data type in this position but found a different one instead.
**Example:**
```
// This will error
users.id + ''
// ^
// Expected `Number`, found `Text`. (ERR-204)
```
**Solution:**
Use the correct type of value for the operation.
## ERR-205: Invalid named expression {#ERR-205}
**Description:** You tried to name an expression that cannot be used as a field.
**Example:**
```aml
// This will error
users | select(abc: relationship(users.id - users.age, true, 'two_way')) | count()
// ^
// Named expression expects a field or scalar value, found `unknown` (ERR-205)
```
**Solution:**
Only use named expressions for fields or scalar values.
## ERR-206: Non-scalar value found {#ERR-206}
**Description:** AQL expected a scalar-like value (number, text, array of text, etc.) but found a different type.
**Solution:**
Similar to [ERR-205](#ERR-205), make sure you're using scalar values where required.
## ERR-209: Dimensionalize not allowed here {#ERR-209}
**Description:** You tried to use `dimensionalize()` inside a metric definition instead of a dimension definition, or nested inside another `dimensionalize()` call.
**Example 1:**
```aml
metric item_quantity {
// This will error
definition: @aql dimensionalize(sum(order_items.quantity), orders.id) ;;
}
```
**Example 2:**
```aml
dimension item_quantity {
definition: @aql dimensionalize(
// This will error
dimensionalize(sum(order_items.quantity), orders.id)
, users.id
) ;;
}
```
**Solution:**
1. For the first case, move the definition to a dimension instead of a metric
2. For the second case, break the nested dimensionalize into separate dimensions
## ERR-210: Cannot reference aql field from sql {#ERR-210}
**Description:** You tried to use an AQL field from an SQL field.
**Example:**
```aml
dimension value {
definition: @aql products.price * order_items.quantity ;;
}
dimension value_discounted {
definition: @sql {{ value }} * {{ discount }} ;;
}
```
**Solution:**
Change the SQL field to an AQL field.
## ERR-211: Right side of pipe must be a function {#ERR-211}
**Description:** Usually due to pipe precedence issues.
**Example:**
```aml
// This will error
users | count(users.id) / 2
```
AQL interprets this as piping users to a division of 2 numbers:
```aml
// This will error
users | (count(users.id) / 2)
```
**Solution:**
Use parentheses to clarify precedence or use the non-pipe form:
```aml
// Correct with parentheses
(users | count(users.id)) / 2
// Correct without pipe
count(users, users.id) / 2
```
## ERR-213: Member not found in module {#ERR-213}
**Description:** You referenced a non-existent member of an AML module.
**Solution:**
Double-check if the referenced member actually exists in the module. See [AML Module](/reference/aml/module) for more information.
## ERR-215: User attribute not found {#ERR-215}
**Description:** The user attribute referenced in the query does not exist.
**Example:**
```aml
// This will error
H.current_user.not_exists + 300
```
**Solution:**
Remove the reference or create the missing user attribute. See [User attribute](/docs/admin/user-attributes) for more information.
## ERR-216: Multiple values in user attribute {#ERR-216}
**Description:** A user attribute with multiple values is being used in a context that requires a single value.
**Example:**
```aml
// H.current_user.groups is set to ['Admin', 'BU', 'DA']
// This will error
users.group == H.current_user.groups
```
**Solution:**
Use operators that work with multiple values:
```aml
// H.current_user.groups is set to ['Admin', 'BU', 'DA']
users.group in H.current_user.groups
```
## ERR-220: Unsupported behavior {#ERR-220}
**Description:** Behaviors that cannot be expressed in all supported SQL dialects, and thus AQL cannot support them correctly.
**Examples:**
- Selecting an interval: `users | select(_interval: interval(1 month))`
- Adding/subtracting intervals: `(interval(1 month) + interval(2 day))`
- Note that `users.created_at + interval(1 month) + interval(2 day)` is fine because it's evaluated left to right without adding intervals directly together
- `running_total(median())`
- `running_total(count_distinct())` - Use `running_total(approx_count_distinct())` as an alternative (supported on Snowflake, BigQuery, Databricks, and Presto/Athena)
**Solution:**
When encountering these errors, you may need to fall back to `@sql` to use database-specific functions. For `running_total(count_distinct())`, consider using `running_total(approx_count_distinct())` which provides an approximate count with 2-3% error margin and is much more performant for large datasets.
## ERR-225: Row expression must contain only dimension {#ERR-225}
**Description:** You included a measure in a row expression for comparison.
**Example:**
```aml
// This will error
users | where({users.id, count_orders} in /* some table */)
```
**Solution:**
Remove the measure from the row expression and filter it in a separate step with [filter](/reference/aql/filter).
## ERR-226: Invalid row column subset {#ERR-226}
**Description:** The target table does not contain the field you want to filter.
**Example:**
```aml
users
// This will error
| where({users.id} in users | select(users.first_name, users.last_name))
```
**Solution:**
Add the missing field to the target of `in` or make sure the row and the target table have matching fields:
```aml
users
| where({users.id} in orders | select(orders.user_id)) // this is fine
```
## ERR-227: Cannot match row with table literal {#ERR-227}
**Description:** You tried to match a multi-column row with a simple array.
**Example:**
```aml
users
// This will error
| where({users.id, users.name} in [1, 2, 3])
```
**Solution:**
Remove other columns and keep only one column to match with the array.
## ERR-229: Field already declared {#ERR-229}
**Description:** A field with the same name has already been declared in modeling or ad-hoc.
**Solution:**
Use a different name for the column.
## ERR-232: Invalid window frame {#ERR-232}
**Description:** When passing a range to a window function, the frame must be valid and cannot go backward.
**Example:**
```aml
window_avg(count(users.id), 5..-1, order: users.created_at | month())
// ^
// Invalid window frame.
```
**Solution:**
Use a valid window frame range.
## ERR-233: Cyclic field dependency {#ERR-233}
**Description:** A cyclic dependency was detected in field definitions, which would lead to infinite recursion.
**Example:**
```aml
dimension field_a {
definition: @aql field_b ;;
}
dimension field_b {
definition: @aql field_c ;;
}
dimension field_c {
definition: @aql field_a ;;
}
```
Cyclic dependency: field_a -> field_b -> field_c -> field_a
**Solution:**
Break the cycle in the field definitions.
## ERR-234: Invalid shorthand aggregation {#ERR-234}
Aggregation functions in AQL normally need a table as their first argument and an expression as the second:
```aml
count(users, users.id)
```
To make your code less verbose, AQL offers a shorthand form that automatically infers the table from the expression:
```aml
count(users.id)
// -> AQL infers this to be count(users, users.id)
sum(order_items.quantity * 2)
// -> AQL infers this to be sum(order_items, order_items.quantity * 2)
sum(products.price * products.discount)
// -> AQL infers this to be sum(products, products.price * products.discount)
```
Here's the catch: this shorthand only works when all fields in the expression come from the same model. You'll get this error when trying to mix fields from different models:
```aml
// This will error
sum(products.price * order_items.amount)
```
The fix is straightforward – explicitly provide the table as the first argument:
```aml
// highlight-next-line
sum(order_items, products.price * order_items.amount)
```
## ERR-235: Unsupported date unit {#ERR-235}
**Description:** You tried to use an incorrect unit for a date function.
**Examples:**
```aml title="Extracting minute from a date"
// This will error
date_part('minute', users.created_date)
```
```aml title="Using a wrong unit"
// This will error
date_part('unix_time', users.created_date)
```
**Solution:**
Consult the documentation for [datetime functions](/reference/aql/time-intelligence-functions) to use the correct units.
## ERR-236: Invalid conditional expression {#ERR-236}
**Description:** Conditions must follow specific forms to be valid in AQL. See [Condition Types](/reference/aql/where#condition-types) in the `where()` documentation for detailed explanations of each condition type.
**Valid Simple Conditions:**
- **Constant Condition** - `dimension operator constant_value`:
```aml
orders.status == 'delivered'
orders.created_at matches @(last 7 days)
orders.status in ['delivered', 'cancelled']
```
- **Single Model Condition** - expressions using fields from a single model:
```aml
date_diff('day', orders.created_at, orders.delivered_at) < 30
// and/or combinations within single model:
orders.status == 'delivered' and orders.amount > 100
```
- **Cross Model Condition** - expressions using fields from multiple models:
```aml
products.price < 30 and order_items.quantity == 3
// and/or combinations across models:
products.category == 'Electronics' or users.country == 'US'
```
**Valid Complex Conditions:**
- **Table Condition** - `dimension in table_expression` or just `table_expression`:
```aml
// Explicit form
orders.status in unique(orders.status)
| where(orders.status != 'delivered')
// Implicit form (internally {users.id} in ...)
top(5, users.id, by: value)
```
- **Semi-additive Condition** - `dimension operator metric`:
```aml
users.age > avg(users.age)
```
**Common Invalid Cases:**
- Using and/or between complex conditions or between complex and simple conditions:
```aml
// Invalid: OR between table condition and simple condition
where(users.id in top(5, users.id, by: users.revenue) or users.country == 'US')
// Invalid: AND between two complex conditions
where(users.age > avg(users.age) and users.id in top(10, users.id, by: users.revenue))
```
**Solutions:**
1. For AND conditions - separate into multiple condition parameters:
```aml
// Instead of: where(users.age > avg(users.age) and users.id in top(10, users.id, by: users.revenue))
// Use:
count(users.id)
| where(users.age > avg(users.age), users.id in top(10, users.id, by: users.revenue))
```
2. For OR conditions - rewrite as a single complex condition.
## ERR-237: Unknown function {#ERR-237}
**Description:** The function being called does not exist.
**Solution:**
Check for typos and refer to the [function cheatsheet](/reference/aql/functions) for all available functions.
## ERR-246: No primary key found for model {#ERR-246}
**Description:** This error occurs when using a [Single Model Condition](/reference/aql/where#single-model-condition) on a model that doesn't have a primary key defined.
**Example:**
```aml
// Model definition without primary key
Model orders {
dimension id {
type: 'number'
}
dimension created_at {
type: 'datetime'
}
}
// This will error
count(orders.id) | where(date_diff('day', orders.created_at, orders.delivered_at) < 30)
```
**Solution:**
Add `primary_key: true` to one dimension in your model definition:
```aml
Model orders {
dimension id {
type: 'number'
primary_key: true // Add this line
}
dimension created_at {
type: 'datetime'
}
}
```
## ERR-238: Cannot match row with table {#ERR-238}
**Description:** Similar to [ERR-227](#ERR-227), but with real tables instead of table literals.
**Example:**
```aml
users
// This will error
| where({users.name} in users | select(users.id))
```
**Solution:**
Use matching column types or cast the right side.
## ERR-239: Duplicate field name {#ERR-239}
**Description:** There are two fields with the same name in the table.
**Example:**
```aml
users
// This will error
| select(one_field: 1, one_field: 1)
```
**Solution:**
Use different names for fields.
## ERR-240: Function expected other type of arguments {#ERR-240}
**Description:** The function expects arguments of one type but received another type.
**Solution:**
Update the input to match the expected type or use a different function.
## ERR-241: Nested window function not allowed {#ERR-241}
**Description:** Window functions cannot be nested inside other window functions.
**Example:**
```aml
window_sum(rank(order: order_items.quantity), ..)
```
**Solution:**
Unnest the window functions by moving the inner window function to a dimension, then use that instead of nesting.
## ERR-243: Field is neither group nor aggregated {#ERR-243}
To understand this error, you first need to understand how dimensions and metrics work in AQL:
- Dimensions are used for grouping data
- Metrics are aggregation expressions that get sliced by those groupings
But, it's a perfectly valid use case to put reference to dimension **inside a metric definition**.
```aml
explore {
dimensions {
products.name,
products.discount // <---This-----------------------------------------+
} // ` +
// `
// |
measures { // |
discounted_value: total_value * products.discount // <- this is referencing this
}
}
```
The error happens when you remove `products.discount` from the dimensions in the exploration. Since the data isn't grouped by `products.discount` anymore, you can't reference it directly in a metric.
This is not unique to AQL, as you can see the same error in SQL.
To fix this issue, consider what you're really trying to do:
1. If you're only referencing dimension fields, create a dedicated dimension in an appropriate model instead:
2. If you're mixing metrics and dimensions, wrap any dimension references in an aggregation function that makes sense for your data:
```aml
total_value * max(products.discount)
```
This way, your field will work regardless of which dimensions are active in the explore.
Alternatively, if you expect the dimension to always be active anyway, you can add it back and just visually hide it in the UI.
## ERR-244: Invalid table condition {#ERR-244}
This error occurs with table conditions (which take the form `dimension in table`). These conditions only work when AQL can clearly determine which fields the table contains at runtime.
For example, at first glance this looks correct:
```aml
// This will error
users.id in users
```
But here's the issue: models in AQL can contain fields from related models as well, so it's not clear which fields should be used for the comparison. AQL can't determine exactly what you're trying to match against.
To fix this, always use `select` to explicitly specify which fields should be used for comparison:
```aml
// highlight-next-line
users.id in users | select(users.id)
```
## ERR-245: Invalid datetime format {#ERR-245}
**Description:** The datetime literal syntax isn't recognized by the natural language parser.
**Solution:**
Check the documentation for [Natural Time Expression](/docs/datetimes/relative-dates#relative-time-expressions) for supported syntax.
## WARN-300: Should follow group with select or filter {#ERR-300}
**Description**: This warning occurs when an aggregate function directly follows a `group()` function without using `select()` or `filter()` in between.
While you might expect `table | group(dimension) | aggregate_function()` to return grouped results, this pattern isn't valid in AQL.
**Why This Happens**: Aggregate functions (like `count()`, `sum()`, etc.) return single scalar values, not grouped tables.
To maintain the grouping structure while performing aggregations, you must use `select()` or `filter()` to properly apply the aggregation within the grouped context.
**Solutions**:
1. **Consider if grouping is necessary**: Metrics in AQL are automatically sliced by dimensions introduced from explore/visualize without explicit grouping. You typically only need manual grouping for nested aggregations.
If you really need nested aggregation, you can write:
```aml title="This works but may trigger the lint warning"
users | group(users.id) | avg(count(orders.id))
```
```aml title="More readable alternative that avoids the warning"
users | group(users.id) | select(count(orders.id)) | avg()
```
2. Use `select()` or `filter()` with your aggregations
```aml title="❌ Incorrect usage"
// This will error
users | group(users.id) | count(orders.id)
```
```aml title="✅ Correct with select()"
users | group(users.id) | select(count(orders.id))
```
```aml title="✅ Correct with named columns"
users | group(users.id) | select(order_count: count(orders.id))
```
```aml title="✅ Correct with filter()"
users | group(users.id) | filter(count(orders.id) > 5)
```
**Key Takeaway**
Always follow `group()` operations with either `select()` or `filter()` when performing aggregations to maintain the proper grouped table structure.
---
## eval
## Definition
Apply metric function(s) to modify the original measure
**Syntax**
```aml
eval(measure, metric_function)
```
**Input**
- *measure*: A define AQL measure
- *metric_function* (repeated): A [metric functions](/reference/aql/metric-function) to modify the measure. E.g. `with_relationships`, `dimensionalize`, etc.
**Output**
A newly modifed measure
## Examples
Let’s say you want to calculate the percentage of sales contributed by each country in your E-commerce business.
You will need to:
- Sum the order values grouped by country
- Divide the sum of each country by the total order value across all countries
Below is the formula for the `percent_of_total` measure that follow the logic above:
```aml
measure percent_of_total {
definition: @aql
sum(orders.order_value) / eval(sum(orders.order_value), exclude(orders.country))
}
```
Technically, the `eval()` function will create a new aggregation context for the `sum(orders.order_value)` expression. This new context excludes `orders.country` dimension, which allows the sum to be calculated over all countries.
For more information about context modifiers, you can refer to the following docs:
- [exclude, exclude_grains, of_all](/reference/aql/of_all)
---
## exact_period
:::tip
Holistics has also supported Period-Comparison on UI via [dashboard-level Period Comparison](/docs/period-comparison#when-building-a-dashboard) and [widget-level Period Comparison](/docs/period-comparison#when-exploring-a-dataset-or-building-a-visualization) which use `exact_period()` and `relative_period()` behind the scenes
:::
## Definition
Calculates a metric within a custom period, it can be used to compare how a metric performs in a specific period compared to another period.
**Syntax**
```aml
exact_period(metric, time_dimension, time_range)
```
```aml title="Examples"
exact_period(orders.total_orders, orders.created_at, @2022-07-01 - 2022-09-01)
// with pipe
orders.total_orders | exact_period(orders.created_at, @2022-07-01 - 2022-09-01)
```
**Input**
- `metric`: A metric that you want to calculate within a custom period
- `time_dimension`: A pre-defined datetime/date dimension that is used for shifting
- `time_range`: A datetime literal that specifies an exact time range for shifting. E.g. `@2022-04-01`, `@2022`, `@(last 2 weeks)` (see [Datetime Literals](/reference/aql/datetime-literal) for more details)
**Output**
The same metric calculated in a custom period.
## Combine with dimension
**Categorical dimension**
When combining with categorical dimension, `exact_period()` is similar to the `where()` function. The difference is that `exact_period()` will override any applied time filter, while `where()` will add the new filter with using `AND` operator logic.
**Time dimension**
Similar to [Dashboard custom period comparison](/docs/period-comparison#custom-period-comparison), when combining with another time dimension, `exact_period()` will add the equivalent period specified in `time_range` argument alongside to the other measures of that time dimension
For more examples on the differences between the two use cases, please check the _Sample Usages_ section below.
## Sample Usages
:::tip
Use filtering in reporting to apply your time condition, then use `exact_period()` to compare it with.
For all the examples below, the filter on reporting is set as `orders.created_at last 3 months`
:::
Let’s say we want to compare total orders from an arbitrary period to the back-to-school season (from July to September).
First, creating measure `count(orders.id)` with custom period from `2022-07-01` to `2022-09-01`
```aml
count(orders.id) | exact_period(orders.created_at, @2022-07-01 - 2022-09-01)
```
### On time-series dimension
Compare week-by-week the last 3 months of count orders to the back-to-school season in
Plot as line chart
### On categorical dimension
Compare the last 3 months of count orders to the back-to-school season for each continent
Plot as bar chart
## See also
- [`relative_period()`](/reference/aql/relative_period)
- [`trailing_period()`](/reference/aql/trailing_period)
- [`period_to_date()`](/reference/aql/period_to_date)
- [`running_total()`](/reference/aql/running_total)
- [Time Comparisons](/as-code/aql/learn/time-comparisons)
---
## Explore Expressions
Explore expressions are expressions that represent a Holistics [Explore](/docs/data-exploration).
Explore Expressions are designed to be used as an **intermediate representation of the Explore**, which can be used to generate the final SQL query to be executed. When a user drag-and-drops dimensions and/or measures into the Explore, Holistics will generate an Explore expression and then use it to generate the final SQL query.
### Structure
```aml
explore {
dimensions {
dimension1,
dimension2
...
}
measures {
measure1,
measure_name2: measure2 // you can also specify a custom name for the measure
...
}
filters { //optional
logical_expression1,
logical_expression2
...
}
relationships { // optional
relationship1,
relationship2
...
}
}
```
### Explore vs Table Expressions
You can think of an Explore expression as a semi-automated version of a Table expression. The key difference is the fact that the Explore expression will automatically choose the right tables to query from based on the dimensions and measures you have defined.
For example, given the following Explore expression:
```aml
explore {
dimensions {
users.name
}
measures {
count_orders: count(orders.id)
}
}
```
It is equivalent to the following Table expression:
```aml
orders
| group(users.name)
| select(users.name, count_orders: count(orders.id))
```
Notice that in the Table expression, we have to manually specify the `orders` table to query from. In the Explore expression, you don't have to do that as Holistics will automatically choose the right tables to query from based on the dimensions and measures you have chosen.
### Examples
```aml
explore {
dimensions {
merchants.name
}
measures {
count_merchants_products: count(products.id)
| with_relationships(products.merchant_id > merchants.id),
count_products_bought: count(products.id)
}
filters {
merchants.name == "John"
}
}
```
---
## AQL Expression
An AQL expression is a structured combination of [data types](/reference/aql/type-index), [operators](/reference/aql/operator), and [functions](/reference/aql/function), allowing you to [query your database](/reference/aql/table-expression) or define [reusable metrics](/reference/aql/metric-expression).
### Types
There are two main types of AQL expressions:
* **[Table expressions](/reference/aql/table-expression)**: Expressions that represent a query that returns a table output, similar to SQL
* **[Metric expressions](/reference/aql/metric-expression)**: Expressions that represent a reusable metric, which is basically some aggregation logic with added context
### Structure
A typical AQL expression often is a combination of AQL functions combined together using the [AQL pipe operator](/reference/aql/operator#pipe), like the followings:
A query that returns the sum of values of all orders from a `order_items` table:
```aml
order_items | select(value: quantity * price) | sum(value)
```
A metric that returns the running total of number of orders in 2023:
```aml
count(orders.id)
| where(orders.created_at is @2023)
| running_total(run: orders.created_at | month())
```
### Dataset
An AQL expression only works within the context of a dataset as it requires knowledge of the models and relationships between them defined in the dataset.
---
## filter
### Definition
Get a subset of rows from `table` that match one or more `condition`.
**Syntax**
```aml
filter(table, condition, condition, ...)
```
```aml title="Examples"
filter(orders, orders.status == 'refunded') //-> Table(orders.id, orders.status, ...)
filter(orders, orders.is_cancelled) // -> Table(orders.id, orders.status, ...)
// with pipe
orders | filter(orders.status == 'refunded') //-> Table(orders.id, orders.status, ...)
orders
| select(orders.id, orders.status)
| filter(orders.status == 'refunded') //-> Table(orders.id, orders.status)
// multiple conditions
orders
| filter(orders.status == 'refunded', orders.is_cancelled)
| select(orders.id) //-> Table(orders.id)
```
**Input**
- `table`: A model reference or the returned table from a previous expression.
- `condition` (**repeatable**): A formula returning a [truefalse](/reference/aql/type-truefalse) value. Each `condition` is evaluated over each row of `table`:
- If the `condition` evaluates to true, the row is included in the output table.
- If the `condition` evaluates to false, the row is excluded from the output table.
:::info
If multiple `condition` are provided, they are evaluated as a logical AND. For example, `filter(orders, orders.status == refunded', orders.is_cancelled)` is equivalent to `filter(orders, and(orders.status == refunded', orders.is_cancelled))`.
:::
**Output**
A new table that contains all rows in `table` that match the `condition`.
### Sample Usages
Any expression that can be used in [select](/reference/aql/select) column, can be used in [filter](/reference/aql/filter) condition.
## See also
- [`where()`](/reference/aql/where): applies on metrics, not tables
- [`select()`](/reference/aql/select)
- [`group()`](/reference/aql/group)
- [where vs filter](/reference/aql/where-vs-filter)
- [Pipe operator](/reference/aql/operator#pipe)
---
## first_value
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
Returns the value of an expression from the first row of the window frame.
**Syntax**
```aml
first_value(expr, order: order_expr, ...)
first_value(expr, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
first_value(count(orders.id), order: users.created_at | asc())
first_value(count(orders.id), order: users.created_at | asc(), order: users.id)
// with partition
first_value(count(orders.id), order: users.created_at | asc(), partition: orders.status)
// Axis-aware examples
first_value(revenue, order: 'rows') // First value in row order
first_value(revenue, order: 'columns', partition: 'rows') // First column value within each row
first_value(sales, order: 'x_axis' | desc()) // First value in reverse row order
```
**Input**
- `expr` (**required**): The expression to retrieve the first value from.
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `first_value(count(orders.id), order: users.created_at | desc())` or `first_value(revenue, order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `first_value(count(orders.id), order: users.created_at, partition: orders.status)` or `first_value(revenue, order: 'columns', partition: 'rows')`
**Output**
The value of the expression from the first row of the window frame. If no partition is specified, the whole table is considered as a single partition.
## See also
- [`last_value()`](/reference/aql/last_value)
- [`nth_value()`](/reference/aql/nth_value)
- [`previous()`](/reference/aql/previous)
- [Window Functions](/reference/aql/window-function)
---
## AQL Functions Overview
AQL functions are the main building blocks of an AQL expression. They transform an input into an output based on specified arguments, and are typically combined using the [pipe operator](/reference/aql/operator#pipe).
This page is the entry point for all AQL functions. Each category below links to its own reference page with the full list of functions, their signatures, and examples.
| Category | What it does |
| --- | --- |
| [Table Functions](/reference/aql/table-function) | Transform a table expression into another table. `select`, `group`, `filter`, `unique`, `top`, `bottom`. |
| [Metric Functions](/reference/aql/metric-function) | Modify the context of a metric expression. Includes Condition, Relationship, LOD, Time-based, and Window functions. |
| [Aggregation Functions](/reference/aql/aggregator-functions) | Collapse multiple rows into a single value. SQL-equivalents of `SUM`, `COUNT`, `AVG`, plus statistical aggregates like `percentile_cont` and `stdev`. |
| [Logical Functions](/reference/aql/logical-functions) | Branch on conditions: `case`, `and`, `or`, `not`, `in`. |
| [Text Functions](/reference/aql/text-functions) | String manipulation: `concat`, `find`, `replace`, regex helpers, padding, case conversion. |
| [Time Intelligence Functions](/reference/aql/time-intelligence-functions) | Date/time helpers: truncation (`day`, `month`, `year`), formatting, unix conversion. |
| [AI Functions](/reference/aql/ai-functions) | LLM-powered helpers for classification, summarization, similarity. *Databricks and Snowflake only.* |
| [Null/Zero Handling Functions](/reference/aql/null-and-zero-functions) | `coalesce`, `nullif`, `safe_divide`. |
| [SQL Passthrough Functions](/reference/aql/sql-passthrough-functions) | Escape hatch to call native database functions when AQL doesn't cover what you need. |
| [Miscellaneous Functions](/reference/aql/miscellaneous-functions) | `cast`, `is_at_level`, and other one-offs. |
## See also
- [AQL Cheatsheet: Functions](/reference/aql/functions): flat alphabetical list to Ctrl-F
- [Operators](/reference/aql/operator): `==`, `+`, `between`, `like`, etc.
- [Pipe operator](/reference/aql/operator#pipe): how functions chain together
---
## group
### Definition
Group data by one or multiple dimensions. In AQL, the [group](#) function serves as an intermediate step for subsequent transformations. If used by itself, [group](#) will return a new table with all unique combination of values in the specified dimensions. To understand more about how [group](#) works, please refer to the [Concepts](/as-code/aql/learn/grouping) page.
**Syntax**
```aml
group(table, dimension, dimension, ...)
```
```aml title="Examples"
group(products, products.category_id, products.name) // -> Table(products.category_id, products.name)
// with pipe
products | group(products.category_id, products.name) // -> Table(products.category_id, products.name)
```
**Input**
- `table`: A model reference or the returned table from a previous expression.
- `dimension` (**repeatable**): A fully-qualified reference to a dimension. The output table will have one row for each unique combination of values in the specified dimensions.
**Output**
A new table with one row for each unique combination of values in the specified dimensions.
### Sample Usages
The special thing about [group](#) is that, it allow you to use [Aggregation Functions](/reference/aql/aggregator-functions) in the context of a row of the grouped table. For example, if you want to create a table with the Total Product for each Product Category, you can use [group](#) and then use the [sum](/reference/aql/aggregator-functions#sum) function inside [select](/reference/aql/select) to get the total product for each category:
This is especially useful when you want to create a metric with nested aggregation. For example, getting the Average of the above table:
## See also
- [`select()`](/reference/aql/select)
- [`filter()`](/reference/aql/filter)
- [`unique()`](/reference/aql/unique)
- [Pipe operator](/reference/aql/operator#pipe)
---
## AQL Reference
This is the reference for AQL: the signatures, the grammar, the function-by-function details. Use it to look something up.
For *concepts* (what AQL is for, how metrics work, how to think about Level of Detail, time comparisons, the order of operations), head to [AQL & Metrics](/as-code/aql/) in the Documentation section.
## How this section is organized
AQL has a small grammar and a large function library. These pages map to that split: a handful of grammar pages, then the function library grouped by category.
The three kinds of expression you can write: table, metric, and explore.
The full set: `==`, `+`, `between`, `like`, `is null`, and the rest.
The `|` that chains functions together by passing the left side into the right.
The type system: scalars, tables, dimensions, fields, and measures.
The function library, grouped by category from aggregators to window functions.
## Quick lookup
If you already know what you're looking for, these flat tables are the fastest way in.
Every function in one scannable table, with full docs a click away.
Every operator with a syntax example for each.
## See also
- [AQL & Metrics](/as-code/aql/): conceptual guides and learning material
- [AML Reference](/reference/aml/): the modeling DSL that AQL queries against
---
## keep_grains
## Definition
The `keep_grains()` function is used when you want your metric to **only be evaluated against some specific grain(s)/dimension(s)**, excluding any other dimensions presented in the query. If the specified grain(s) are not included in the query, the metric will be **evaluated against the whole dataset**. Note that **all filters applied on the excluded dimensions are also ignored**.
:::tip Knowledge Checkpoint
This documentation assumes you are familiar with the following concepts:
- [Expressions](/reference/aql/expression)
- [Context](/as-code/aql/learn/metric-context)
:::
## Syntax
```aml
keep_grains(metric, model, ..., dimension, ...)
```
```aml title="Examples"
keep_grains(users.total_spent, users.email)
users.total_spent | keep_grains(users.email)
keep_grains(users.total_spent, users)
users.total_spent | keep_grains(users)
```
**Input**:
- `metric`: An AQL Metric
- `model` **(optional, repeatable)**: The model that contains the dimensions that you want to keep. If a model is specified, the function will keep all the dimensions in that model.
- `dimension` **(optional, repeatable)**: The dimension that you want to keep. If a dimension was not specified here, it will be ignored.
- `keep_filters` **(optional)**: A boolean value that specifies whether to keep the filters applied on the excluded dimensions. Default is false.
**Output:**
A metric that was only evaluated against **at most** the specified grain(s)/dimension(s).
## 1. When the grains is a specific Dimension
Let's say that you have defined a measure called `total_spent` in side model `users`:
```aml
Model users {
dimension id {}
dimension name {}
dimension email {}
measure total_spent {
definition: @aql
// Use a specific field as the dimension to keep
sum(order_items.order_value) | keep_grains(users.email)
;;
}
}
```
### **Explore 1**:
When exploring the dataset, if you include the following fields:
- **Dimension**: `users.email`, `order_items.order_month`
- **Measure**: `users.total_spent`
Then the measure `users.total_spent` will be grouped by `users.email`, and the value will be duplicated for each month:
users.email
order_items.order_month
users.total_spent
abc@mail.com
1
1000
abc@mail.com
2
1000
abc@mail.com
3
1000
def@mail.com
1
1500
def@mail.com
2
1500
def@mail.com
3
1500
### **Explore 2**:
If you do not include the `users.email` field when exploring data:
- **Dimension**: `order_items.order_month`
- **Measure**: `users.total_spent`
Then the `users.total_spent` measure will not be grouped by any dimension, and will return the total value across all users (In this case: 1000 + 1500 = 2500):
order_items.order_month
users.total_spent
1
2500
2
2500
3
2500
## 2. When the grain is a Model
```aml
Model users {
dimension id {}
dimension name {}
dimension email {}
measure total_spent {
definition: @aql sum(order_items.order_value) | keep_grains(users) ;; // Use the whole Model as the grain
}
}
```
When using the whole `users` model as the grain to keep, if you do not include any fields from the `users` model when exploring, then the `users.total_spent` measure will not be grouped by any dimension, and will return the total value across all users:
order_items.order_month
users.total_spent
1
2500
2
2500
3
2500
### Examples
Suppose that you have a dataset containing the `order_items` and `users` model:
```aml
Model order_items {
dimension user_id {}
dimension quantity {}
dimension price {}
dimension order_status {}
dimension order_value {
definition: @aql order_items.price * order_items.quantity
}
measures total_amount {
definition: @aql sum(order_items.order_value) ;;
}
measures total_orders {
definition: @aql count_distinct(order_items.order_id) ;;
}
}
Model users {
dimension full_name {}
}
```
You want to calculate the **Customer’s Average Order Value** metric with the following logic:
1. Calculate the customer’s total orders’ value
2. Calculate the customer’s total number of orders
3. Divide (1) by (2)
You would want the two aggregations (1) and (2) to always be calculated against the **customer**, and not any other dimensions.
The metric will be defined in the ecommerce dataset like this:
```aml
Dataset ecommerce {
...
models: [order_items, users]
relationships: [
relationship(order_items_ext.user_id > users.id, true)
]
// AOV measure definition
measure customer_aov {
label: 'Customer AOV'
type: 'number'
definition: @aql
(order_items.total_amount * 1.0 / order_items.total_orders) | keep_grains(users) ;;
}
// With distributive property, you can also use keep_grains() on each measure:
measure customer_aov_dist {
label: 'Customer AOV'
type: 'number'
definition: @aql
(order_items.total_amount * 1.0 | keep_grains(users))
/ (order_items.total_orders | keep_grains(users));;
}
}
```
Result:
Even when we add another dimension like Order Status, the Customer AOV metric will still only be calculated for Customers, and remain unaffected by the new dimension:
## See also
- [`of_all()`](/reference/aql/of_all): opposite operation: exclude specified grains
- [`dimensionalize()`](/reference/aql/dimensionalize)
- [Level of Detail](/as-code/aql/learn/level-of-detail)
- [Order of Operations](/as-code/aql/order-of-operations)
---
## last_value
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
Returns the value of an expression from the last row of the window frame.
**Syntax**
```aml
last_value(expr, order: order_expr, ...)
last_value(expr, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
last_value(count(orders.id), order: users.created_at | asc())
last_value(count(orders.id), order: users.created_at | asc(), order: users.id)
// with partition
last_value(count(orders.id), order: users.created_at | asc(), partition: orders.status)
// Axis-aware examples
last_value(revenue, order: 'rows') // Last value in row order
last_value(revenue, order: 'columns', partition: 'rows') // Last column value within each row
last_value(sales, order: 'x_axis' | desc()) // Last value in reverse row order
```
**Input**
- `expr` (**required**): The expression to retrieve the last value from.
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `last_value(count(orders.id), order: users.created_at | desc())` or `last_value(revenue, order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `last_value(count(orders.id), order: users.created_at, partition: orders.status)` or `last_value(revenue, order: 'columns', partition: 'rows')`
**Output**
The value of the expression from the last row of the window frame. If no partition is specified, the whole table is considered as a single partition.
## See also
- [`first_value()`](/reference/aql/first_value)
- [`nth_value()`](/reference/aql/nth_value)
- [`next()`](/reference/aql/next)
- [Window Functions](/reference/aql/window-function)
---
## Logical Functions(Aql)
Logical functions return value based on some logical conditions.
### case when
```aml
case(when: condition_expression, then: value_expression, else: value_expression)
```
```aml title="Examples"
case(when: users.gender == 'm', then: 'male') // else: null
case(
when: users.gender == 'm', then: 'male',
when: users.gender == 'f', then: 'female',
else: 'others'
)
```
**Description**
The CASE statement goes through conditions and returns a value when the first condition is met (like an IF-THEN-ELSE statement).
**Return type**
Vary
**Sample Usages**
Given an AQL expression as below:
```aml
case(
when: users.gender == 'm', then: 'male',
when: users.gender == 'f', then: 'female',
else: 'others'
)
```
And the result would be:
| gender | case |
| ------ | ------ |
| m | male |
| f | female |
| m | male |
You can also use unicode within a CASE statement to make your metric returns emoji:
```aml
case(
when: count(orders.id) > 10
, then: '✅'
, else: cast(count(orders.id), 'text')
)
```
When used with the gender dimension, the above expression result in:
| gender | order_count |
| ------ | ------ |
| m | ✅ |
| f | 5 |
| m | ✅ |
---
### and
```aml
and(condition_expression, ...)
```
```aml title="Examples"
and(products.id >= 2, products.id <= 8)
and(products.id >= 2, products.id <= 8, products.id != 5)
```
**Description**
Logical AND compares between multiple [Truefalse](/reference/aql/type-truefalse) expressions and returns true when all expressions are true.
**Return type**
Truefalse
**Example**
Given an AQL expression as below:
```aml
and(products.id >= 2, products.id <= 8)
```
And the result would be:
| id | and |
| --- | ----- |
| 1 | false |
| 2 | true |
| 8 | true |
| 9 | false |
---
### or
```aml
or(condition_expression, ...)
```
```aml title="Examples"
or(products.id <= 2, products.id >= 8)
or(products.id <= 2, products.id >= 8, products.id != 5)
```
**Description**
Logical OR compares between multiple [Truefalse](/reference/aql/type-truefalse) expressions and returns true when at least one expression is true.
**Return type**
Truefalse
**Sample Usages**
Given an AQL expression as below:
```
or(products.id <= 2, products.id >= 8)
```
And the result would be:
| id | or |
| --- | ----- |
| 1 | true |
| 4 | false |
| 7 | false |
| 9 | true |
---
### not
```aml
not(condition_expression)
```
```aml title="Examples"
not(products.id <= 2)
```
**Description**
Logical NOT takes a single [Truefalse](/reference/aql/type-truefalse) expression and returns true when the expression is false.
**Return type**
Truefalse
**Sample Usages**
Given an AQL expression as below:
```
not(is(products.id, null))
```
And the result would be:
| id | not |
| --- | ----- |
| 1 | true |
| | false |
| 3 | true |
| 4 | true |
---
### is
:::warning
This function was deprecated in favor of the [is](/reference/aql/operator) operator and only kept for backward compatibility with [Business Calculation](/docs/business-calculation). Please use the operator instead.
:::
```aml
is(field_expression, value_expression)
```
```aml title="Examples"
is(products.id, null)
```
**Description**
Logical IS evaluates the given statement and return either `true` or `false`.
**Return type**
Truefalse
**Sample Usages**
Given an AQL expression as below:
```
is(products.id, null)
```
And the result would be:
| id | not |
| --- | ----- |
| 1 | false |
| | true |
| 3 | false |
| 4 | false |
---
### in
:::warning
This function was deprecated in favor of the [in](/reference/aql/operator) operator and only kept for backward compatibility with [Business Calculation](/docs/business-calculation). Please use the operator instead.
:::
```aml
in(field_expression, value_expression, value...)
```
```aml title="Examples"
in(users.name, 'bob', 'alice', 'jack')
```
**Description**
`in` operator takes a field expression and a list of values. Return true if that list of values contains the value of that field expression.
**Return type**
Boolean
**Sample Usages**
Given an AQL expression as below:
```
in(users.name, 'bob', 'alice', 'jack')
```
And the result would be:
| name | in |
| ----- | ----- |
| bob | true |
| alice | true |
| peter | false |
---
## Macros
Macros are a special type of function that receives expressions instead of values. Its usage in AQL is to modify the expression that is passed into them. They enable the ability to compose metrics together in a flexible and maintainable way.
- [where](/reference/aql/where)
- [keep, keep_grains](/reference/aql/keep)
- [exclude, exclude_grains, of_all](/reference/aql/of_all)
- [dimensionalize](/reference/aql/dimensionalize)
---
## Mathematical Functions
### abs
```aml
abs(number)
```
```aml title="Examples"
abs(-1) // => 1
```
**Description**
Returns the absolute value of a number.
**Return type**
Number
---
### sqrt
```aml
sqrt(number)
```
```aml title="Examples"
sqrt(9) // => 3
sqrt(10) // => 3.1622776601683795
```
**Description**
Returns the square root of a number.
**Return type**
Number
---
### ceil
```aml
ceil(number)
```
```aml title="Examples"
ceil(1.1) // => 2
ceil(1.9) // => 2
```
**Description**
Returns the smallest integer greater than or equal to a number.
**Return type**
Number
### floor
```aml
floor(number)
```
```aml title="Examples"
floor(1.1) // => 1
floor(1.9) // => 1
```
**Description**
Returns the largest integer less than or equal to a number.
**Return type**
Number
### round
```aml
round(number)
round(number, scale)
```
```aml title="Examples"
round(1.1) // => 1
round(1.1, 0) // => 1
round(1.9, 0) // => 2
round(1.12345, 2) // => 1.12
round(-1.5) // => -2
```
**Description**
Returns the rounded value of a number to a specified number of decimal places (scale). Round away from zero if the fractional part of the number is greater than 0.5; otherwise, round towards zero.
**Return type**
Number
### trunc
```aml
trunc(number)
trunc(number, scale)
```
```aml title="Examples"
trunc(1.1) // => 1
trunc(1.1, 0) // => 1
trunc(1.9, 0) // => 1
trunc(1.12345, 2) // => 1.12
```
**Description**
Returns the truncated value of a number to a specified number of decimal places (scale).
**Return type**
Number
### exp
```aml
exp(number)
```
```aml title="Examples"
exp(1) // => 2.718281828459045
```
**Description**
Returns the value of the constant $e$ raised to the power of a number.
**Return type**
Number
### ln
```aml
ln(number)
```
```aml title="Examples"
ln(1) // => 0
```
**Description**
Returns the natural logarithm ($\log_{e}$) of a number.
**Return type**
Number
### log10
```aml
log10(number)
```
```aml title="Examples"
log10(100) // => 2
```
**Description**
Returns the base 10 logarithm ($\log_{10}$) of a number.
**Return type**
Number
### log2
```aml
log2(number)
```
```aml title="Examples"
log2(8) // => 3
```
**Description**
Returns the base 2 logarithm ($\log_{2}$) of a number.
**Return type**
Number
### pow
```aml
pow(base, exponent)
```
```aml title="Examples"
pow(2, 3) // => 8
```
**Description**
Returns the value of a base raised to the power of an exponent.
**Return type**
Number
### mod
```aml
mod(dividend, divisor)
```
```aml title="Examples"
mod(5, 2) // => 1
```
**Description**
Returns the remainder of a division operation.
**Return type**
Number
### div
```aml
div(dividend, divisor)
```
```aml title="Examples"
div(5, 2) // => 2
```
**Description**
Returns the integer quotient of a division operation.
**Return type**
Number
### sign
```aml
sign(number)
```
```aml title="Examples"
sign(5) // => 1
sign(-5) // => -1
sign(0) // => 0
```
**Description**
Returns the sign of a number: 1 if the number is positive, -1 if the number is negative, and 0 if the number is zero.
**Return type**
Number
### radians
```aml
radians(degrees)
```
```aml title="Examples"
radians(180) // => 3.141592653589793
```
**Description**
Converts degrees to radians.
**Return type**
Number
### degrees
```aml
degrees(radians)
```
```aml title="Examples"
degrees(pi()) // => 180
```
**Description**
Converts radians to degrees.
**Return type**
Number
### pi
```aml
pi()
```
```aml title="Examples"
pi() // => 3.141592653589793
```
**Description**
Returns the value of the constant π.
**Return type**
Number
### acos
```aml
acos(number)
```
```aml title="Examples"
acos(cos(pi())) // => 3.141592653589793
```
**Description**
Returns the arccosine of a number.
**Return type**
Number
### asin
```aml
asin(number)
```
```aml title="Examples"
asin(sin(pi() / 2)) // => 1.5707963267948966
```
**Description**
Returns the arcsine of a number.
**Return type**
Number
### atan
```aml
atan(number)
```
```aml title="Examples"
atan(tan(pi() / 4)) // => 0.7853981633974483
```
**Description**
Returns the arctangent of a number.
**Return type**
Number
### atan2
```aml
atan2(y, x)
```
```aml title="Examples"
atan2(2, pi()) // => 0.6366197723675814
```
**Description**
Returns the 2-argument arctangent.
**Return type**
Number
### cos
```aml
cos(number)
```
```aml title="Examples"
cos(pi()) // => -1
```
**Description**
Returns the cosine of a number.
**Return type**
Number
### sin
```aml
sin(number)
```
```aml title="Examples"
sin(pi() / 2) // => 1
```
**Description**
Returns the sine of a number.
**Return type**
Number
### tan
```aml
tan(number)
```
```aml title="Examples"
tan(pi() / 4) // => 1
```
**Description**
Returns the tangent of a number.
**Return type**
Number
### cot
```aml
cot(number)
```
```aml title="Examples"
cot(pi() / 4) // => 1
```
**Description**
Returns the cotangent of a number.
**Return type**
Number
---
## Metric Expressions
Metric expressions are expressions that represent a reusable metric.
### Structure
```aml
source_table_expression (optional)
| aggregation_expression
| metric_function1 (optional)
| metric_function2 (optional)
| ...
```
A metric expression needs to contain at least one [aggregation expression](/reference/aql/metric-expression#aggregation-expression), together with an optional [source table expression](/reference/aql/metric-expression#source-table-expression), and an optional [metric context](/reference/aql/metric-expression#metric-context).
### Aggregation expression
An aggregation expression represents the core logic of the metric expression.
It can be a single aggregation function:
```aml
avg(users.age)
```
or an arithmetic combination of aggregation expressions:
```aml
// difference between average order value and average discount
avg(orders.total_value) - avg(orders.discount)
// average price of products
sum(products.price) / count(products.id)
// country order percentagage
count(orders.id) * 1.0 /
(count(orders.id) | of_all(countries))
```
### Source table expression
A source [table expression](/reference/aql/table-expression) is only required if the aggregation function **aggregates data from multiple models** and you want to **explicitly set the model to aggregate from**.
For examples:
```aml
// counts number of users from users table
count(users.id)
// same as above and thus the source table expression can be omitted
users | count(users.id)
// count number of times some user placed an order
// The 'orders' part is the table expression
orders | count(users.id)
```
Another use case is [nested aggregation](/as-code/aql/cookbook/level-of-detail#use-case-1-higher-lod--nested-aggregation) where you want to aggregate on an already agregated measure. For example:
Given an existing measure:
```aml
Model users {
// Average order value of a user
measure aov {
definition: @aql
sum(order_items, order_items.quantity * products.price) * 1.0
/ count_distinct(orders.id)
;;
}
}
```
You can create a new nested aggregation like this
```aml
// Max of average order value of a user
// The part before 'max()' is the table expression
users | group(users.id) | select(user_aov: users.aov) | max()
```
### Metric context
Every metric expression has a corresponding context, which includes the followings:
* The condition applied to the expression, can be modified with [where](/reference/aql/where) function
* The relationship structure defined in the dataset where the expression is located, can be modified with [with_relationships](/reference/aql/with_relationships) function
* The [level of detail](/as-code/aql/cookbook/level-of-detail) associated with the expression, can be modified with [LOD functions](/reference/aql/metric-function#lod-functions)
* The window function logic applied to the expression, can be modified with [window functions](/reference/aql/metric-function#window-functions)
The context can be manipulated using a combination of [metric functions](/reference/aql/metric-function). See the examples below for more clarity.
### Examples
Count number of users:
```aml
count(users.id)
```
Count number of male users with [where](/reference/aql/where):
```aml
count(users.id) | where(users.gender == 'Male')
```
Cross-model metric:
```aml
sum(products.price * order_items.quantity)
```
Total order values, regardless of dimensions, leveraging [LOD functions](/reference/aql/metric-function#lod-functions):
```aml
sum(products.price * order_items.quantity) | exclude(order_items)
```
Running Total Orders by Month
```aml
count(orders.id) | running_total(run: orders.created_at | month())
```
---
## Metric Functions Overview
Metric functions transform an input metric expression into another metric expression, usually by modifying the [context](/as-code/aql/learn/metric-context) that the input is evaluated in: its filters, its relationships, its level of detail, or its time window.
They split into five categories:
| Category | When to reach for it | Functions |
| --- | --- | --- |
| **Condition** | Restrict which rows feed into the metric. | [`where`](/reference/aql/where) |
| **Relationship** | Override the dataset's default joins for a specific metric. | [`with_relationships`](/reference/aql/with_relationships) |
| **Level of Detail** | Force the metric to compute at a specific grain (independent of the report's grouping). | [`of_all`](/reference/aql/of_all), [`keep`](/reference/aql/keep), [`dimensionalize`](/reference/aql/dimensionalize), [`percent_of_total`](/reference/aql/percent_of_total) |
| **Time-based** | Shift, accumulate, or compare across time periods. | [`running_total`](/reference/aql/running_total), [`period_to_date`](/reference/aql/period_to_date), [`exact_period`](/reference/aql/exact_period), [`relative_period`](/reference/aql/relative_period), [`trailing_period`](/reference/aql/trailing_period) |
| **Window** | Rank, navigate to neighboring rows, or aggregate across a moving frame. Analogous to SQL window functions (see the [Window Functions overview](/reference/aql/window-function)). | `rank`, `dense_rank`, `ntile`, `percent_rank`, `first_value`, `last_value`, `nth_value`, `previous`, `next`, `window_sum`, `window_avg`, `window_min`, `window_max`, `window_count`, `window_stdev`, `window_stdevp`, `window_var`, `window_varp` |
## See also
- [Level of Detail](/as-code/aql/learn/level-of-detail): conceptual guide to LoD functions
- [Time Comparisons](/as-code/aql/learn/time-comparisons): conceptual guide to time-based functions
- [Window Functions](/reference/aql/window-function): conceptual + reference for window functions
- [Order of Operations](/as-code/aql/order-of-operations): when each function category fires in the pipeline
---
## Miscellaneous Functions
### cast
```aml
cast(value, type)
```
```aml title="Examples"
cast('2021-01-01', 'date') // -> 2021-01-01
cast('2000.4', 'number') // -> 2000.4
cast('2000', 'int') // -> 2000
```
**Input**
- `value`: The value to be casted
- `type`: Specifies the data type to cast to. Supported types include:
- `date`
- `datetime`
- `number`
- `text`
- `truefalse`
- `int` or `integer` (special cases of the `number` type)
**Note:** The `int` and `integer` types are treated as `number` but will be cast to the integer type of the underlying database. Use `int` or `integer` when you need to ensure the value is an integer, especially for functions that require integer input (e.g., [mod](/reference/aql/math-functions#mod)).
**Output**
The value casted to the specified type.
### is_at_level
```aml
is_at_level(dimension)
```
```aml title="Examples"
explore {
dimensions {
users.email
}
measures {
_email_active: is_at_level(users.email), // -> true
_name_active: is_at_level(users.name), // -> false
_nested_agg: unique(users.name) | select(
case(
when: is_at_level(users.name) // -> true
, then: count(users.id)
, else: 0
)
) | avg()
}
}
```
**Description**
This function returns `true` if the specified dimension is active in the Level of Detail (LoD) context, otherwise it returns `false`.
**Return type**
Truefalse
**Sample Usages**
The `is_at_level` function is used to conditionally modify the behavior of a measure based on the active dimensions in the Level of Detail (LoD) context. This function is typically paired with the `case` function to address scenarios such as calculating the *Percent of Parent (Subtotal)*.
**Example: Calculating Percentage Contributions in a Pivot Table**
Imagine you have a Pivot Table with three hierarchical levels: `Continent`, `Country`, and `City`. You want to calculate the percentage of sales contribution at each level:
- **City to Country**
- **Country to Continent**
- **Continent to Grand Total**
To achieve this, the `is_at_level` function can identify the active dimension in the LoD context, allowing you to compute the correct percentage.
Here's how you can implement this logic:
```aml
case(
when: is_at_level(cities.name)
, then: sum(sales.amount) / (sum(sales.amount) | of_all(cities.name))
, when: is_at_level(countries.name)
, then: sum(sales.amount) / (sum(sales.amount) | of_all(countries.name))
, when: is_at_level(countries.continent)
, then: sum(sales.amount) / (sum(sales.amount) | of_all(countries.continent))
, else: 1
)
```
**Explanation**:
- **`is_at_level(cities.name)`**: Checks if the current dimension level is `City`. If true, the calculation returns the percentage of sales for each city relative to the total sales of all cities. Since `of_all` is only applied to `cities.name`, this will be all cities within the same country.
- **`is_at_level(countries.name)`**: Checks if the current dimension level is `Country`. If true, the calculation returns the percentage of sales for each country relative to the total sales of all countries within the same continent. Note that we don't need to include `cities.name` in the `of_all` function because if it's active, it would've been caught by the first condition.
- **`is_at_level(countries.continent)`**: Checks if the current dimension level is `Continent`. If true, the calculation returns the percentage of sales for each continent relative to the total sales of all continents.
- **`else`**: If none of the conditions are met, it simply returns 1. This is useful for the Grand Total row, where we don't need to calculate the percentage.
This approach allows you to dynamically adjust the measure based on the active dimension, ensuring accurate percentage calculations across different hierarchical levels.
---
## next
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window function that returns the value of the next row at an offset relative to the current row.
**Syntax**
```aml
next(expr, offset, order: order_expr, ...)
next(expr, offset, order: order_expr, ...)
next(expr, offset, order: order_expr, ..., reset: partition_expr, ...)
next(expr, offset, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
next(count(users.id), order: count(users.id) | desc())
next(count(users.id), 2, order: users.created_at | month())
next(count(users.id), 4, order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
next(revenue, order: 'rows') // Next row value
next(revenue, 2, order: 'columns', partition: 'rows') // Two columns ahead within each row
next(sales, order: 'x_axis' | desc()) // Next value in reverse order
```
**Input**
- `expr` (**required**): An expression of the value
- `offset` (**optional**): The offset of the next row relative to the current row. If not specified, the default value is 1.
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The value of the next row in a offset relative to the current row.
## Sample Usages
Please refer to the sample usages in [previous](/reference/aql/previous#sample-usages).
## See also
- [`previous()`](/reference/aql/previous)
- [`first_value()`](/reference/aql/first_value)
- [`last_value()`](/reference/aql/last_value)
- [Window Functions](/reference/aql/window-function)
---
## nth_value
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
Returns the value of an expression from the Nth row of the window frame, where N is a positive integer.
**Syntax**
```aml
nth_value(expr, index, order: order_expr, ...)
nth_value(expr, index, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
nth_value(count(orders.id), 2, order: users.created_at | asc())
nth_value(count(orders.id), 3, order: users.created_at | asc(), order: users.id)
// with partition
nth_value(count(orders.id), 2, order: users.created_at | asc(), partition: orders.status)
// Axis-aware examples
nth_value(revenue, 3, order: 'rows') // 3rd value in row order
nth_value(revenue, 2, order: 'columns', partition: 'rows') // 2nd column value within each row
nth_value(sales, 5, order: 'x_axis' | desc()) // 5th value in reverse row order
```
**Input**
- `expr` (**required**): The expression to retrieve the Nth value from.
- `index` (**required**, **number**): The index of the row from which to retrieve the value (1-based).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `nth_value(count(orders.id), 2, order: users.created_at | desc())` or `nth_value(revenue, 3, order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `nth_value(count(orders.id), 2, order: users.created_at, partition: orders.status)` or `nth_value(revenue, 2, order: 'columns', partition: 'rows')`
**Output**
The value of the expression from the Nth row of the window frame. If no partition is specified, the whole table is considered as a single partition.
**Notes**
This function is not supported in SQL Server.
## See also
- [`first_value()`](/reference/aql/first_value)
- [`last_value()`](/reference/aql/last_value)
- [Window Functions](/reference/aql/window-function)
---
## ntile
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window function that divides the rows within a partition into a specified number of ranked groups. It assigns a rank (bucket number) to each row, based on the ordering specified.
**Syntax**
```aml
ntile(ranks, order: order_expr)
ntile(ranks, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
ntile(4, order: count(orders.id) | desc())
ntile(4, order: count(orders.id) | desc(), order: average(users.age))
// with partition
ntile(4, order: count(orders.id) | desc(), partition: orders.status)
// Axis-aware examples
ntile(4, order: 'rows') // Divide into 4 groups by row order
ntile(3, order: 'columns' | desc()) // Divide into 3 groups by column order (descending)
ntile(5, order: revenue | desc(), partition: 'rows') // Divide into 5 groups within each row
```
**Input**
- `ranks` (**required**, **number**): The number of ranked groups to divide the rows into.
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `ntile(4, order: count(orders.id) | desc())` or `ntile(4, order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `ntile(4, order: count(orders.id), partition: orders.status)` or `ntile(4, order: revenue, partition: 'rows')`
**Output**
The rank group (or bucket number) the current row belongs to, between 1 and `ranks`. If no partition is specified, the whole table is considered as a single partition.
## See also
- [`rank()`](/reference/aql/rank)
- [`dense_rank()`](/reference/aql/dense_rank)
- [`percent_rank()`](/reference/aql/percent_rank)
- [Window Functions](/reference/aql/window-function)
---
## Null/Zero Functions
### coalesce
```aml
coalesce(val1, val2, ...)
```
```aml title="Examples"
coalesce(users.yearly_payment, users.quarterly_payment, 0)
```
**Description**
This function returns the first non-null value in a list
**Return type**
Vary
**Sample Usages**
Given an AQL expression as below:
```aml
coalesce(yearly_payment, quarterly_payment, monthly_payment)
```
The result would be:
| Name | Yearly Payment | Quarterly Payment | Monthly Payment | Payment (coalesce) |
| ----- | -------------- | ----------------- | --------------- | ------------------ |
| Alice | 70.00 | NULL | NULL | 70.00 |
| Billy | NULL | 35.00 | NULL | 35.00 |
| Conte | NULL | NULL | 6.00 | 6.00 |
---
### nullif
```aml
nullif(val1, val2)
```
```aml title="Examples"
nullif(sales_target, sales_current)
```
**Description**
This function returns NULL if two expressions are equal, otherwise it returns the first expression.
**Return type**
Vary
**Sample Usages**
Given an AQL expression as below:
```aml
nullif(sales_target, sales_current)
```
The result would be:
| Sales Person | Sales Target | Sales Current | Target to be achieved (nullif) |
| ------------ | ------------ | ------------- | ------------------------------ |
| Andy | 10,000 | 10,000 | null |
| Billy | 23,000 | 18,000 | 23,000 |
| Cindy | 21,000 | 21,000 | null |
| Danny | 0 | 10,000 | 0 |
---
### safe_divide
```aml
safe_divide(dividend, divisor)
```
```aml title="Examples"
safe_divide(total_sales, count_orders)
```
**Description**
Equivalent to the division operator (X / Y), but returns NULL if divisor is 0.
**Return type**
Vary
---
## of_all
## Definition
The function `of_all()` is used when you want your metric to **not be evaluated against certain dimensions/grains**, even when that dimension/grain is present during exploration. **All filters applied on the excluded dimensions are also ignored**.
This is useful when you mix an aggregation of a lower grain with a higher grain, like when calculating [Percent of Total](/as-code/aql/cookbook/aql-percent-of-total)
:::tip
`exclude_grains()`, `exclude()` are aliases of `of_all()`. We recommend using `of_all()` when writing metrics to express your intent more clearly. E.g. `orders.total_orders | of_all(orders)` reads more naturally than `orders.total_orders | exclude(orders)`.
:::
**Syntax**
```aml
of_all(metric, model, ..., dimension, ...)
```
```aml title="Examples"
orders.total_orders | of_all(orders) // -> Total orders of all orders
orders.total_orders | exclude(orders) // -> Total orders of all orders
orders.total_orders | of_all(orders.status) // -> Total orders of all order statuses
orders.total_orders | of_all(orders, keep_filters: true) // -> Total orders of all orders, keeping outer filters
```
**Input**
- `metric`: An AQL metric
- `model` (**optional**, **repeatable**): The model that you want to exclude from the calculation, along with all dimensions within this model and related dimensions through an n-1 relationship
- `dimension` (**optional**, **repeatable**): The dimension that you want to exclude from the calculation
- `keep_filters:` (**optional**): A boolean value that specifies whether to keep the filters applied on the excluded dimensions. Default is `false`.
:::tip
`keep_filters` is useful when you want the metric to be calculated on the filtered data of the excluded dimensions. For example, to calculate the percentage of each status of orders after certain date:
- You cannot use `of_all(orders.status)` because it would return the percentage over orders across all time.
- Instead, use `of_all(orders.status, keep_filters: true)` to calculate the percentage over the filtered orders.
:::
**Output**
A metric with the specified dimensions excluded from their Level of Detail (LoD) context.
## Sample Usages
Suppose you have an Ecommerce dataset that contains information about order items, and you want to calculate the percentage of sales contribution by each country:
```aml
Model order_items {
dimension quantity {}
dimension price {}
dimension country {}
dimension order_value {
definition: @aql order_items.price * order_items.quantity
}
measure total_amount {
definition: @aql sum(order_items.order_value) ;;
}
}
```
Your **Percent of Total** metric would be calculated by dividing `total_orders` of each country by the `total_orders` of all countries. The definition of that metric will be as follows:
```aml
Dataset ecommerce {
models: [order_items]
metric percent_of_total {
definition: @aql
order_items.total_amount / (order_items.total_amount | of_all(order_items))
;;
}
}
```
With `of_all(order_items)`, the `total_amount` metric in the divisor will not be grouped or filtered by any dimensions in the `order_items` model, as well as any other models that have n-1 relationship with it.
Result:
The Percent of Total is still correctly calculated when you include dimensions from `users` model:
Note that, since we're using `of_all()` without `keep_filters: true`, the percentage is calculated over orders of all time. This approach is useful, for instance, when determining the percentage of sales attributed to the top five countries. From the results, we can observe that the top five countries contribute 54% of the total sales, and we can also see the contribution of each individual country to overall sales.
In the case we want to compare the top five countries against each other, we can use `of_all()` with `keep_filters: true` to calculate the percentage based on the filtered data.
```aml
metric percent_of_total {
definition: @aql
order_items.total_amount / (order_items.total_amount | of_all(order_items, keep_filters: true))
;;
}
```
With that, we can see that, while United States only contributes to 11% of all sales, they contribute to 21% of the sales of the top five countries.
## See also
- [`keep()`](/reference/aql/keep): opposite operation: restrict to specified grains
- [`dimensionalize()`](/reference/aql/dimensionalize): pin a metric to a specific grain
- [Level of Detail](/as-code/aql/learn/level-of-detail)
- [Order of Operations](/as-code/aql/order-of-operations)
---
## AQL Operators
A key component of AQL's query syntax involves the utilization of operators to generate more precise metrics.
## Pipe
The pipe operator `|` chains expressions left-to-right by passing the left side as the first positional argument to the function on the right.
```aml
expr | fn(args) // equivalent to:
fn(expr, args)
```
Example:
```aml
orders | filter(orders.country = 'Singapore') | sum(orders.total_value)
```
For the conceptual treatment (when pipes shine, the table-vs-scalar mental model, debugging tips), see [The pipe operator](/as-code/aql/learn/pipe).
## Logical Operator
These operators serve to compare values and yield a [truefalse](/reference/aql/type-truefalse) result.
### Text
| Operator | Example | Description |
| --- | --- | --- |
| `==` `is`| `products.name == 'Dandelion'` `products.name is 'Dandelion'` | Equal to |
| `!=` `is not`| `products.name != 'Rock'` `products.name is not 'Rock'`| Not equal to |
| `like` | `products.name like '%Dan'` | Match the pattern specified |
| `not like` | `products.name not like '%Dan'` | Not match the pattern specified |
| `ilike` | `products.name ilike '%dan'` | Match the pattern specified, case insensitive |
| `not ilike` | `products.name not ilike '%dan'` | Not match the pattern specified, case insensitive |
| `is null` | `products.name is null` | Include if the value is null |
| `is not null` | `products.name is not null` | Include if the value is not null |
### List
| Operator | Example | Description |
| --- | --- | --- |
| `in` | `products.name in ['Dandelion', 'Rock']` | Include if the value is in the list |
| `not in` | `products.name not in ['Dandelion', 'Rock']` | Include if the value is not in the list |
### Truefalse
| Operator | Example | Description |
| --- | --- | ---
| `is` | `orders.is_paid is true` | Equal to |
| `is not` | `orders.is_paid is not true` | Not equal to|
| `is null` | `orders.is_paid is null` | Include if null |
| `is not null` | `orders.is_paid is not null` | Include if not null |
### Number
| Operator | Example | Description |
| --- | --- | --- |
| `==` `is`| `order_items.discount == 0.5` `order_items.discount is 0.5`| Equal to|
| `!=` `is not`| `order_items.discount != 1` `order_items.discount is not 1`| Not equal to|
| `>` | `order_items.discount > 0.5` | Greater than |
| `<` | `order_items.discount < 0.5` | Less than |
| `is null` | `order_items.discount is null`| Include if null |
| `is not null` | `order_items.discount is not null`| Include if not null |
### Datetime
Right hand side of datetime operator takes a [datetime scalar type](/reference/aml/date-format) as input and always starts with `@` token. Datetimes can be expressed in a fully supported format as `@YYYY-MM-DD HH:MM:SS`, in shorter variations like `@YYYY-MM`, or a relative datetime (relative to the current real world time) like `@(last 7 days)`.
:::tip
For more information on datetime, please refer to:
- [Datetime Literals](/reference/aql/datetime-literal)
- [Natural Time Expression](/docs/datetimes/relative-dates)
:::
Operator
Example
Meaning
Description
==
- orders.created_at == @2022
- orders.created_at == @(last 7 days)
- orders.created_at equal to 2022-01-01 00:00:00- orders_created_at equal to the first timestamp of the last 7 days
Include data that equal to an absolute timestamp
ismatchesmatch
- orders.created_at is @2022
- orders.created_at match @(last 7 days)
- orders.created_at is in the period of the year 2022- order.created_at is in the period of the last 7 days
Include data that are in a time period
!=
orders.created_at != @2022-01
- orders.created_at is not equal to 2022-01-01 00:00:00
Include data that do not equal to an absolute timestamp
is not
orders.created_at is not @2022-01
- orders.created_at is not in the period of 2022-01
Include data that are not in a time period
<
orders.created_at < @2022
- orders.created_at is before the year 2022
Include data that are before a specific time period
>
orders.created_at > @(yesterday)
- orders.created_at is after yesterday
Include data that are after a specific time period
is null
orders.created_at is null
- orders.created_at is null
Include if the value is null
is not null
orders.created_at is not null
- orders.created_at is not null
Include if the value is not null
---
## percent_of_total
## Definition
`percent_of_total` calculates the percentage of a metric relative to a specified total type. This function simplifies percentage calculations by automatically handling the division and dimension context in visualizations.
## Syntax
```aml
percent_of_total(metric, total_type)
```
```aml title="Examples"
// Calculate percentage of each row against row total
percent_of_total(orders.total_revenue, 'row_total')
// Calculate percentage of each column against column total
percent_of_total(users.count, 'column_total')
// Calculate percentage against grand total
percent_of_total(products.sales_amount, 'grand_total')
// Using axis aliases for better readability
percent_of_total(orders.count, 'x_axis_total') // same as 'row_total'
percent_of_total(orders.count, 'legend_total') // same as 'column_total'
```
## Input
- `metric`: A metric or aggregation expression that you want to calculate the percentage for
- `total_type`: A string specifying which total to use as the denominator:
- `'row_total'` or `'x_axis_total'`: Percentage of row total (across all columns in that row)
- `'column_total'` or `'legend_total'`: Percentage of column total (across all rows in that column)
- `'grand_total'`: Percentage of the overall total
## Output
A percentage value representing the metric's proportion of the specified total. The result is automatically formatted as a percentage (0-100).
## Sample Usages
### Pivot Table with Row and Column Percentages
When working with pivot tables, you can calculate percentages across different dimensions:
```aml
metric users_count = count(ecommerce_users.id);
explore {
dimensions {
rows {
_year: ecommerce_users.created_at | year()
}
columns {
_gender: ecommerce_users.gender
}
}
measures {
users_count: users_count,
pct_row: percent_of_total(users_count, 'row_total'), // % by gender within each year
pct_col: percent_of_total(users_count, 'column_total'), // % by year within each gender
pct_grand: percent_of_total(users_count, 'grand_total'), // % by both year and gender across all users
}
}
```
This creates a pivot table showing:
- `pct_row`: What percentage of users in each year belong to each gender
- `pct_col`: What percentage of users of each gender joined in each year
- `pct_grand`: What percentage each cell represents of the total user count
### Using Alias Names
For better readability in visualizations, you can use alias names that correspond to chart axes:
```aml
percent_of_total(users_count, 'x_axis_total') // same as 'row_total'
percent_of_total(users_count, 'legend_total') // same as 'column_total'
```
### Non-Pivot Tables
When using `percent_of_total` in a regular (non-pivot) table:
```aml
metric users_count = count(ecommerce_users.id);
explore {
dimensions {
_year: ecommerce_users.created_at | year(),
_gender: ecommerce_users.gender
}
measures {
users_count: users_count,
pct_row: percent_of_total(users_count, 'row_total'), // 100% (no column grouping)
pct_col: percent_of_total(users_count, 'column_total'), // % of grand total
pct_grand: percent_of_total(users_count, 'grand_total'), // % of grand total
}
}
```
In non-pivot tables:
- `column_total` and `grand_total` calculate the percentage of the overall total
- `row_total` and `x_axis_total` default to 100% since there is only one column per metric, making the row total equal to the metric value itself
## See Also
- [Level of Detail (LOD) Guide](/as-code/aql/cookbook/level-of-detail)
- [Percent of Total Guide](/as-code/aql/cookbook/aql-percent-of-total)
- [of_all() Function](/reference/aql/of_all)
---
## percent_rank
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
Returns the relative percentile of a row within a partition of a table. The value is between 0 and 1, inclusive.
**Syntax**
```aml
percent_rank(order: order_expr, ...)
percent_rank(order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
percent_rank(order: count(orders.id) | desc())
percent_rank(order: count(orders.id) | desc(), order: average(users.age))
// with partition
percent_rank(order: count(orders.id) | desc(), partition: orders.status)
// Axis-aware examples
percent_rank(order: 'rows') // Percentile rank by row order
percent_rank(order: 'columns' | desc()) // Percentile rank by column order (descending)
percent_rank(order: revenue | desc(), partition: 'rows') // Percentile rank within each row
```
**Input**
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `percent_rank(order: count(orders.id) | desc())` or `percent_rank(order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `percent_rank(order: count(orders.id), partition: orders.status)` or `percent_rank(order: revenue, partition: 'rows')`
**Output**
The percentile rank of the current row within its partition, as a value between 0 and 1. If no partition is specified, the whole table is considered as a single partition.
## See also
- [`rank()`](/reference/aql/rank)
- [`dense_rank()`](/reference/aql/dense_rank)
- [`ntile()`](/reference/aql/ntile)
- [Window Functions](/reference/aql/window-function)
---
## period_to_date
## Definition
Calculates a metric from the beginning of year, quarter, month, etc to the current date. For example, you can apply this computation to determine the total orders you have accumulated in sales from the beginning of the year up until the present date, also known as Year-to-Date(YTD) metric
**Syntax**
```aml
period_to_date(measure, date_part, date_dimension)
```
```aml title="Examples"
count(orders.id) | period_to_date('year', orders.created_at) // Return year-to-date total orders
count(orders.id) | period_to_date('month', orders.created_at) // Return month-to-date total orders
```
**Input**
- `measure` (**required**): The measure on which you want to apply the `period_to_date()` function
- `date_part` (**required**): The time period for which the measure should reset. It can be one of the following options. Can be one of the followings: `'year'`, `'quarter'`, `'month'`, `'week'`, `'day'`
- `date_dimension` (**required**): The date dimension that is used to determine the reset period
**Output**
A metric that calculates input `measure` from the beginning of year, quarter, month, etc to the current date.
## Sample Usages
## Frequently Asked Questions
### What happens if the visualization date grain is finer than the period specified in period_to_date?
In that case, `period_to_date` will use the last date in the current period as the _anchor date_, and calculates the metric from the start of month/quarter/year/etc (of the anchor date) to the anchor date.
Examples:
- For a Year-to-Date Metric displayed monthly, the metric for March 2022 will be calculated from January 1st, 2022, to March 31st, 2022.
- For a Month-to-Date Metric displayed daily, the metric for Jan 3rd, 2022, will be calculated from January 1st, 2022, to January 3rd, 2022.
### What happens if the visualization date grain is coarser than the period specified in period_to_date or if the visualization has no date dimension at all?
In that case, `period_to_date` will use the last date in the current period as the _anchor date_, and calculates the metric in the last month/quarter/year/etc (specified in `period_to_date`) of the anchor date.
For example:
- For a Month-to-Date Metric displayed yearly, the metric for 2022 will be calculated from December 1st, 2022, to December 31st, 2022 (assuming the last record of 2022 is in December 2022).
- For a Year-to-Date Metric displayed as a KPI Metric (a single number), the metric will be calculated from January 1st, 2022, to March 31st, 2022 (assuming the last date in the dataset is in March 2022).
## See also
- [`running_total()`](/reference/aql/running_total)
- [`exact_period()`](/reference/aql/exact_period)
- [`relative_period()`](/reference/aql/relative_period)
- [`trailing_period()`](/reference/aql/trailing_period)
- [Time Comparisons](/as-code/aql/learn/time-comparisons)
---
## previous
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window function that returns the value of the previous row at an offset relative to the current row.
**Syntax**
```aml
previous(expr, offset, order: order_expr, ...)
previous(expr, offset, order: order_expr, ...)
previous(expr, offset, order: order_expr, ..., reset: partition_expr, ...)
previous(expr, offset, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
previous(count(users.id), order: count(users.id) | desc())
previous(count(users.id), 2, order: users.created_at | month())
previous(count(users.id), 4, order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
previous(revenue, order: 'rows') // Previous row value
previous(revenue, 2, order: 'columns', partition: 'rows') // Two columns back within each row
previous(sales, order: 'x_axis' | desc()) // Previous value in reverse order
```
**Input**
- `expr` (**required**): An expression of the value
- `offset` (**optional**): The offset of the previous row relative to the current row. If not specified, the default value is 1.
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The value of the previous row in a offset relative to the current row.
## Sample Usages {#sample-usages}
The most common use case for `previous` is to calculate the difference between the current row and the previous row. For example, to calculate the difference in sales between the current year and the previous year:
:::caution
`previous` works at the row level so it will return the previous row regardless if it actually is the previous year or not. You can see in the example above that _diff is equal 997 for 2022 because the previous row is 2020, not 2021.
If you work with time series data, please consider using [relative_period](/reference/aql/relative_period) instead.
:::
## See also
- [`next()`](/reference/aql/next)
- [`first_value()`](/reference/aql/first_value)
- [`last_value()`](/reference/aql/last_value)
- [Window Functions](/reference/aql/window-function)
---
## rank
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window function that returns the rank of rows within a partition of a table. Tie values are assigned the same rank. The next rank in the sequence is not consecutive. E.g. 1, 1, 3, 4, 4, 6, ...
To get consecutive rank, use [dense_rank](/reference/aql/dense_rank).
**Syntax**
```aml
rank(order: order_expr)
rank(order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
rank(order: count(orders.id) | desc())
rank(order: count(orders.id), order: average(users.age))
// with partition
rank(order: count(orders.id), partition: orders.status)
// Axis-aware examples
rank(order: 'rows') // Rank by row/x-axis order
rank(order: 'columns' | desc()) // Rank by column/legend order (descending)
rank(order: revenue | desc(), partition: 'rows') // Rank within each row
```
**Input**
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- E.g. `rank(order: count(orders.id) | desc())` or `rank(order: 'rows')`
- `partition` (**repeatable**, **optional**): A field that is used for partitioning the table. You can use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. E.g. `rank(order: count(orders.id), partition: orders.status)` or `rank(order: revenue, partition: 'rows')`
**Output**
Rank of the current row within its partition (if no partition is specified, the whole table is considered as a single partition). If two rows are tied for a rank, each tied rows receives the same rank. The next rank in the sequence is not consecutive. E.g. 1, 1, 3, 4, 4, 6, ...
## Sample Usages
Using rank in a dimension definition is straight-forward. Remember that the rank is calculated against all rows in the model.
```aml
dimension ranking_by_age {
label: 'Rank By Age'
type: 'number'
hidden: false
description: ''
definition: @aql rank(order: users.age | desc()) ;;
}
```
You can see that users with the same age are assigned the same rank
You can also use it in `filter()` to filter out the top 10 users by age:
## See also
- [`dense_rank()`](/reference/aql/dense_rank): same value gets same rank, no gaps
- [`percent_rank()`](/reference/aql/percent_rank)
- [`ntile()`](/reference/aql/ntile)
- [Window Functions](/reference/aql/window-function)
---
## relative_period
:::tip
Holistics has also supported Period Comparison on UI via [Dashboard-level Period Comparison](/docs/period-comparison#when-building-a-dashboard) and [widget-level Period Comparison](/docs/period-comparison#when-exploring-a-dataset-or-building-a-visualization) which use `exact_period()` and `relative_period()` behind the scenes.
:::
## Definition
Calculates a metric in the **active time-range** shifted by a specified interval. The active time-range can be the time range specified in a filter (if no time dimension is active) or the time period in each row of a time dimension.
**Syntax**
```aml
relative_period(metric, time_dimension, offset)
```
```aml title="Examples"
// Using interval for specific time shift
relative_period(orders.total_orders, orders.created_at, interval(-1 month))
// Using number for period-based shift (requires time grain)
relative_period(orders.total_orders, orders.created_at, -1)
// with pipe
orders.total_orders | relative_period(orders.created_at, interval(-1 month))
orders.total_orders | relative_period(orders.created_at, -1)
```
**Input**
- `metric`: A metric that you want to calculate within a relative interval
- `time_dimension`: A pre-defined datetime/date dimension that is used for shifting
- `offset`: Either:
- A relative interval for shifting from the time condition. E.g. `interval(-1 month)`, `interval(-7 days)`
- A number that specifies how many periods to shift based on the current time grain. E.g. `-1` for previous month when grouped by month, or previous day when grouped by day. **Note: When using a number, the date dimension in visualization must have a time grain applied (e.g., `| month()`, `| day()`).**
**Output**
The same metric calculated in the active time-range shifted by the specified interval.
## Number-based Offset Example
When using a number instead of an interval, the offset is determined by the time grain applied to the date dimension:
```aml
explore {
dimensions {
rows {
rollup(order_items.created_at | month()) // Time grain is month
}
}
measures {
this_month_count: count(order_items.order_id),
last_month_count: count(order_items.order_id) | relative_period(order_items.created_at, -1) // Previous month
}
filters {
order_items.created_at matches @2021
}
}
```
In this example:
- Since the dimension is grouped by month (`| month()`), the number `-1` means "previous month"
- If the dimension was grouped by quarter (`| quarter()`), the number `-1` would mean "previous quarter"
- Positive numbers shift forward in time, negative numbers shift backward
- If no time grain is specified, an error will be raised
## Combine with dimension
**Categorical dimension**
When combining with categorical dimension with no filtering on the time dimension, `relative_period()` will have no effect on the metric.
**Categorical dimension with filtering on time dimension**
When combining with categorical dimension with filtering on the time dimension, `relative_period()` will shift the time condition by the specified interval in `time_interval` argument.
**Time dimension**
Similar to [Dashboard previous period comparison](/docs/period-comparison#previous-period-comparison), when combining with another time dimension, `relative_period()`, will shift the time period in each row of the time dimension by the specified interval in `time_interval` argument.
## Sample Usages
We’ll implement a quick Period Comparison analysis on the `total_orders` metric which is defined as below
```aml
Dataset ecommerce {
(...)
metric total_orders {
label: "Total Orders"
type: "number"
definition: @aql count(orders.id) ;;
}
}
```
And then define `total_orders_last_month` as this expression:
```aml
count(orders.id) | relative_period(orders.created_at, interval(-1 month))
```
### Examples
For all the examples below, the filter on reporting is set as `orders.created_at last 1 month`
**Compare `total_orders` from with the preceding month**
**Compare `total_orders` week-by-week with the previous month**
**Compare `total_orders` with the previous month for each continent**
## See also
- [`exact_period()`](/reference/aql/exact_period)
- [`trailing_period()`](/reference/aql/trailing_period)
- [`period_to_date()`](/reference/aql/period_to_date)
- [Time Comparisons](/as-code/aql/learn/time-comparisons)
---
## running_total
## Definition
Calculate a metric from the starting of time to the current period. This function is used to calculate a running total of a metric along specified dimensions.
By default, the running total will be calculated after filtering (`keep_filters: true`). To calculate a running total of all data, ignoring any filter on the running dimensions, you can add a `keep_filters: false` parameter to the function. Here is a comparison between `keep_filters: true` and `keep_filters: false`:
**Syntax**
```aml
running_total(measure)
running_total(measure, running_dimension, ...)
running_total(measure, running_dimension, ..., keep_filters: false)
running_total(measure, running_dimension, ..., fill_missing: true)
```
```aml title="Examples"
running_total(orders.total_orders, orders.created_at)
// with pipe
orders.total_orders | running_total(orders.created_at | year())
// with multiple running dimensions
running_total(orders.total_orders, orders.created_at, orders.status)
// with keep_filters: false
running_total(orders.total_orders, orders.created_at, keep_filters: false)
// fill missing values due to pivot
running_total(orders.total_orders, orders.created_at, fill_missing: true)
```
**Input**
- `measure`: The measure that you want to turn into a running measure.
- `running_dimension` (**optional**, **repeatable**): The dimension you want your aggregation to run along. If not specified, the returned measure will run along all date dimensions that in your exploration
- `keep_filters:` (**optional**): A boolean value that specifies whether to keep the filters applied on the running dimensions. Default is `true`.
- `fill_missing` (**optional**): A boolean value that specifies whether to fill the missing values due to pivot mechanism. Default is `false`
**Output**
New measure that runs along the specified dimension(s)
## Sample Usages {#sample-usages}
In the following examples, let’s assume that we have an `orders` model with the following fields
```aml
Model orders {
dimension id {}
dimension created_at {}
measure total_orders {
definition: @aql count(orders.id);;
}
}
```
#### Simple running sum
Below is an example of how to define a running total measure to calculate cumulative orders on a monthly basis
```aml
orders.total_orders | running_total(orders.created_at)
```
#### Running dimension is not included in exploration
If the specified running dimension is not included in the exploration, the returned value will be the same as the original measure before applying the `running_total()` function
```aml
orders.total_orders | running_total(orders.created_at)
```
#### Running dimension is coarser than grouping dimensions
You have a running total metric that runs along a time dimension in `year` grain as below
```aml
orders.total_orders | running_total(orders.created_at | year())
```
If the dimension included in the exploration has a finer granularity than the specified running dimension, the value will be repeated for any record that shares the same coarser granularity
In this case, the running total will still be calculated for the whole year, and the yearly value will be repeated for every month existing in the data:
#### Fill missing values due to Pivot mechanism
For example, you have a table with `order statuses` and `order dates`, running totals are only calculated for the statuses present on a given day in table form (it can’t fill missing statuses because there are no rows for them)

To fill missing values in this case, you can use `fill_missing: true`
```ts
running_total(
total_orders,
fill_missing: true
)
```

## See also
- [`period_to_date()`](/reference/aql/period_to_date)
- [`exact_period()`](/reference/aql/exact_period)
- [`relative_period()`](/reference/aql/relative_period)
- [`trailing_period()`](/reference/aql/trailing_period)
- [Time Comparisons](/as-code/aql/learn/time-comparisons)
---
## running_total! (deprecated)
:::info
This function will be deprecated soon and replaced by another function
:::
## Definition
Modify a measure into a running measure that accumulates values over one or multiple dimensions as it progresses. Note that the running total will be calculated on all data in the model ignoring any filter on the running dimensions.
To calculate a running total of only the data that is visible in the exploration, please use the [running_total](/reference/aql/running_total) function instead. Here is a comparison between `running_total` and `running_total!`:
**Syntax**
```aml
running_total!(measure)
running_total!(measure, running_dimension, ...)
```
```aml title="Examples"
running_total!(orders.total_orders, orders.created_at)
// with pipe
orders.total_orders | running_total!(orders.created_at | year())
// with multiple running dimensions
running_total!(orders.total_orders, orders.created_at, orders.status)
```
**Input**
- `measure`: The measure that you want to turn into a running measure.
- `running_dimension` (**optional**, **repeatable**): The dimension you want your aggregation to run along. If not specified, the returned measure will run along all dimensions that in your exploration
**Output**
New measure that runs along the specified dimension(s) with all data in the model ignoring any filter on the running dimensions.
## Sample Usages
See the [running_total](/reference/aql/running_total#sample-usages) documentation for sample usages.
---
## select
### Definition
Run one expression or multiple expression over each row of a table and use the output to return a new table with the same number of row. In AQL, the [select](#) function serves as an intermediate step for subsequent transformations.
**Syntax**
```aml
select(table, expr1, expr2, ...)
select(table, col_name: expr1, ...)
```
```aml title="Examples"
select(orders, orders.id, orders.status) // -> Table(orders.id, orders.status)
// with pipe
orders | select(orders.id, orders.status) | select(orders.id) // -> Table(orders.id)
// named column
orders | select(orders.status, formatted_status: concat('Status: ', orders.status))
```
**Input**
- `table`: A model reference or the returned table from a previous expression.
- `expr` (**repeatable**): An expression to evaluate for each row of `table`.
:::caution
You need to name the column in the output table (e.g. `formatted_status: concat('Status: ', orders.status)`). Only expression that has clear fully-qualified column name (e.g. `orders.id`) can be used directly and reference as-is in the output table.
:::
**Output**
A table with the same number of rows as the input table, but with only the specified columns. E.g. `select(orders, orders.id, formatted_status: concat('Status: ', orders.status)` will return a table with 2 columns: `orders.id` and `formatted_status`.
| orders.status | formatted_status |
|---------------|------------------|
| pending | Status: pending |
| cancelled | Status: cancelled|
| pending | Status: pending |
### Sample Usages
## See also
- [`filter()`](/reference/aql/filter)
- [`group()`](/reference/aql/group)
- [`unique()`](/reference/aql/unique)
- [Pipe operator](/reference/aql/operator#pipe)
---
## SQL Passthrough Functions
SQL passthrough functions provide a way to leverage native database-specific functions that aren't directly supported in AQL. These functions act as a bridge, allowing you to pass SQL function calls directly to your underlying database while maintaining type safety in your AQL queries.
### sql_text
```aml
sql_text('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
sql_text('UPPER', users.name) // -> Converts name to uppercase
sql_text('CONCAT_WS', '-', users.first_name, users.last_name) // -> Joins first and last name with hyphen
sql_text('SUBSTRING', users.email, 1, 5) // -> Extracts first 5 characters
```
**Description**
Calls a native SQL function that returns a text/string value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Text
---
### sql_number
```aml
sql_number('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
sql_number('POWER', orders.amount, 2) // -> Squares the order amount
sql_number('GREATEST', orders.quantity, 1) // -> Returns the greater of quantity or 1
sql_number('FLOOR', orders.amount) // -> Rounds down to nearest integer
```
**Description**
Calls a native SQL function that returns a numeric value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Number
---
### sql_datetime
```aml
sql_datetime('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
sql_datetime('DATE_ADD', orders.created_at, interval(7 days)) // -> Adds 7 days to created_at
sql_datetime('CONVERT_TZ', orders.created_at, '+00:00', '+07:00') // -> Converts timezone
sql_datetime('DATE_SUB', orders.created_at, interval(1 month)) // -> Subtracts 1 month
```
**Description**
Calls a native SQL function that returns a datetime value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Datetime
---
### sql_date
```aml
sql_date('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
sql_date('CURRENT_DATE') // -> Returns current date
sql_date('DATE', orders.created_at) // -> Extracts date portion from datetime
sql_date('DATE_SUB', orders.created_at, interval(1 month)) // -> Subtracts 1 month
```
**Description**
Calls a native SQL function that returns a date value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Date
---
### sql_truefalse
```aml
sql_truefalse('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
sql_truefalse('REGEXP_LIKE', users.email, '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$') // -> Validates email format
sql_truefalse('STARTS_WITH', users.name, 'John') // -> Checks if name starts with 'John'
```
**Description**
Calls a native SQL function that returns a boolean/truefalse value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Truefalse
---
## Aggregation Versions
Each SQL passthrough function has an aggregation version that can be used with aggregate SQL functions.
### agg_text
```aml
agg_text('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
agg_text('GROUP_CONCAT', orders.status) // -> Concatenates all status values
agg_text('STRING_AGG', users.email, ',') // -> Aggregates emails with comma separator
agg_text('LISTAGG', products.name, '; ') // -> Lists all product names
```
**Description**
Calls a native SQL aggregate function that returns a text/string value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Text
---
### agg_number
```aml
agg_number('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
agg_number('STDDEV_SAMP', orders.amount) // -> Sample standard deviation
agg_number('VAR_POP', sales.revenue) // -> Population variance
agg_number('PERCENTILE_CONT', 0.5, orders.amount) // -> Median using percentile
```
**Description**
Calls a native SQL aggregate function that returns a numeric value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Number
---
### agg_datetime
```aml
agg_datetime('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
agg_datetime('MAX', orders.created_at) // -> Latest order date
agg_datetime('MIN', users.registered_at) // -> Earliest registration date
```
**Description**
Calls a native SQL aggregate function that returns a datetime value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Datetime
---
### agg_date
```aml
agg_date('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
agg_date('MAX', orders.order_date) // -> Latest order date
agg_date('MIN', events.event_date) // -> Earliest event date
```
**Description**
Calls a native SQL aggregate function that returns a date value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Date
---
### agg_truefalse
```aml
agg_truefalse('FUNCTION_NAME', param1, param2, ...)
```
```aml title="Examples"
agg_truefalse('BOOL_AND', orders.is_paid) // -> All orders paid
agg_truefalse('BOOL_OR', users.is_active) // -> Any user active
```
**Description**
Calls a native SQL aggregate function that returns a boolean/truefalse value. The first argument is the SQL function name (as a string), followed by any parameters that function requires.
**Return type**
Truefalse
---
## Important Notes
:::warning Database-Specific Behavior
SQL passthrough functions execute native database functions, which means:
- Function names and syntax vary between databases (PostgreSQL, MySQL, BigQuery, etc.)
- Not all SQL functions are available on all database platforms
- You're responsible for ensuring the SQL function exists in your target database
- The query may fail if the function doesn't exist or has different syntax on your database
:::
:::tip When to Use SQL Passthrough
Use SQL passthrough functions when:
- You need database-specific functionality not available in AQL
- You're working with specialized functions unique to your database platform
- You need to maintain compatibility with existing SQL code
Consider using native AQL functions instead when they're available, as they provide database-agnostic portability.
:::
---
## Table Expressions
Table expressions are expressions that represent a query that returns a table output, similar to SQL. They are typically [Table Functions](/reference/aql/function#table-functions) combined using the [AQL pipe operator](/reference/aql/operator#pipe).
### Structure
```aml
model_name
| table_function1 (optional)
| table_function2 (optional)
| ...
```
### Examples
This table expresion is equivalent to `select * from users` in SQL:
```aml
users
```
Selecting a subset of fields:
```aml
users | select(users.id, users.gender)
```
Filtering data with [filter function](/reference/aql/filter):
```aml
users
| filter(gender == 'Female')
| select(id, name)
```
There is no need for explicitly joins like in SQL for **cross-model select**:
```aml
users
| select(users.name, country.name)
```
---
## Table Functions
Table functions transform an input table expression into an output table result.
| Function | Syntax | Purpose |
| --- | --- | --- |
| [select](/reference/aql/select) | `select(table, field1, field2, [,...])` | Selects the fields to be returned in the query result. |
| [group](/reference/aql/group) | `group(table, field1, field2, [,...])` | Groups the result set by the specified field(s). |
| [filter](/reference/aql/filter) | `filter(table, condition1, condition2, [,...])` | Filters the result set to include only rows that satisfy the specified conditions |
| [unique](/reference/aql/unique) | `unique(table, field1, field2, [,...])` | Returns the unique values of the specified field(s). |
---
## Text Functions
### concat
```aml
concat(value1, value2, ...)
```
```aml title="Examples"
concat('Hello', ' ', 'World') // -> Hello World
concat('Hello', ' ', 'World', '!') // -> Hello World!
```
**Description**
This function concatenates multiple strings into a single string.
**Return type**
Text
---
### find
```aml
find(text, substring)
```
**Description**
Returns the position of the first occurrence of a substring within a text string.
The returned position is a positive number starting from 1. Returns 0 if the substring is not found.
**Aliases**
`find_index`
**Return type**
Number
**Examples**
```aml title="Find the position of 'World' in 'Hello World'"
find('Hello World', 'World') // Returns 7
```
```aml title="Find the position of 'o' in 'Hello World'"
find('Hello World', 'o') // Returns 5
```
```aml title="Find the position of 'Universe' in 'Hello World'"
find('Hello World', 'Universe') // Returns 0
```
---
### left
```aml
left(text, length)
```
**Description**
Returns the leftmost characters of a text string, up to the specified length.
**Return type**
Text
**Examples**
```aml title="Get the first 5 characters of 'Hello World'"
left('Hello World', 5) // Returns 'Hello'
```
```aml title="Get the first 10 characters of 'Hello'"
left('Hello', 10) // Returns 'Hello'
```
---
### right
```aml
right(text, length)
```
**Description**
Returns the rightmost characters of a text string, up to the specified length.
**Return type**
Text
**Examples**
```aml title="Get the last 5 characters of 'Hello World'"
right('Hello World', 5) // Returns 'World'
```
```aml title="Get the last 10 characters of 'Hello'"
right('Hello', 10) // Returns 'Hello'
```
---
### mid
```aml
mid(text, start, length)
```
**Description**
Extracts a substring of a specified length from a text string, starting at a given position.
* `text`: the text string
* `start`: the position where the extraction starts. It must be a positive number. The first position in the text is 1.
* `length`: the (maximum) length of the substring
**Return type**
Text
**Examples**
```aml title="Extract 'World' from 'Hello World'"
mid('Hello World', 7, 5) // Returns 'World'
```
```aml title="Extract 'llo' from 'Hello World' starting from position 3"
mid('Hello World', 3, 3) // Returns 'llo'
```
---
### len
```aml
len(text)
```
**Description**
Returns the length of a text string (number of characters).
**Return type**
Number
**Examples**
```aml title="Get the length of 'Hello World'"
len('Hello World') // Returns 11
```
```aml title="Get the length of an empty string"
len('') // Returns 0
```
---
### lpad
```aml
lpad(text, length, pad_string)
```
**Description**
Pads the left side of a text string with a specified pad string until it reaches the specified length. If the text is already longer than the length it will be truncated.
**Return type**
Text
**Examples**
```aml title="Pad 'Hello' to length 10 with ' '"
lpad('Hello', 10, ' ') // Returns ' Hello'
```
```aml title="Pad 'Hello' to length 10 with 'abc'"
lpad('Hello', 10, 'abc') // Returns 'abcabHello'
```
```aml title="Pad 'Hello World' to length 10 with '0'"
lpad('Hello World', 10, '0') // Returns 'Hello Worl'
```
---
### rpad
```aml
rpad(text, length, pad_string)
```
**Description**
Pads the right side of a text string with a specified pad string until it reaches the specified length. If the text is already longer than the length it will be truncated.
**Return type**
Text
**Examples**
```aml title="Pad 'Hello' to length 10 with ' '"
rpad('Hello', 10, ' ') // Returns 'Hello '
```
```aml title="Pad 'Hello' to length 10 with 'abc'"
rpad('Hello', 10, 'abc') // Returns 'Helloabcab'
```
```aml title="Pad 'Hello World' to length 10 with '0'"
rpad('Hello World', 10, '0') // Returns 'Hello Worl'
```
---
### lower
```aml
lower(text)
```
**Description**
Converts a text string to lowercase.
**Return type**
Text
**Examples**
```aml title="Convert 'Hello World' to lowercase"
lower('Hello World') // Returns 'hello world'
```
```aml title="Convert 'HELLO' to lowercase"
lower('HELLO') // Returns 'hello'
```
---
### upper
```aml
upper(text)
```
**Description**
Converts a text string to uppercase.
**Return type**
Text
**Examples**
```aml title="Convert 'Hello World' to uppercase"
upper('Hello World') // Returns 'HELLO WORLD'
```
```aml title="Convert 'hello' to uppercase"
upper('hello') // Returns 'HELLO'
```
---
### trim
```aml
trim(text)
```
**Description**
Removes leading and trailing whitespace from a text string.
**Return type**
Text
**Examples**
```aml title="Trim whitespace from ' Hello World '"
trim(' Hello World ') // Returns 'Hello World'
```
```aml title="Trim whitespace from ' Hello '"
trim(' Hello ') // Returns 'Hello'
```
---
### ltrim
```aml
ltrim(text)
```
**Description**
Removes leading whitespace from a text string.
**Return type**
Text
**Examples**
```aml title="Trim leading whitespace from ' Hello World'"
ltrim(' Hello World') // Returns 'Hello World'
```
```aml title="Trim leading whitespace from ' Hello '"
ltrim(' Hello ') // Returns 'Hello '
```
---
### rtrim
```aml
rtrim(text)
```
**Description**
Removes trailing whitespace from a text string.
**Return type**
Text
**Examples**
```aml title="Trim trailing whitespace from 'Hello World '"
rtrim('Hello World ') // Returns 'Hello World'
```
```aml title="Trim trailing whitespace from ' Hello '"
rtrim(' Hello ') // Returns ' Hello'
```
---
### regexp_extract
```aml
regexp_extract(text, regex, [occurrence], [group: _group], [flags: _flags])
```
**Description**
Extracts a substring from a text string that matches a regular expression pattern.
**Return type**
Text
**Examples**
```aml title="Extract a number from a string"
regexp_extract('Product123', '[0-9]+') // Returns '123'
```
```aml title="Extract the second word from a string"
regexp_extract('Hello World Example', '\\w+', 2) // Returns 'World'
```
```aml title="Extract text with case-insensitive match"
regexp_extract('Hello World', 'hello', flags: 'i') // Returns 'Hello'
```
```aml title="Extract a substring with a capture group"
regexp_extract('Product123.3', '(\\d+)\\.\\d+', group: 1) // Returns '123'
```
**Parameters**
- `text`: The text string to search within
- `regex`: The regular expression pattern to match (exact regex syntax depends on the database)
- `occurrence` (optional): The position of the occurrence to return. E.g. `1` means return the **first** occurrence that matches the regex.
- `group` (optional): The capture group to extract from the matched occurrence (BigQuery does not support this)
- `flags` (optional): Flags to modify the behavior of the regular expression matching (supported flags depend on the database)
**Notes**
This function is not supported in the following databases:
- SQL Server
For BigQuery, the `group` parameter is not supported. BigQuery will automatically extract the *first* capture group if one exists in the regex. BigQuery will throw an error if the regex has multiple capture groups. To use grouping without extracting a specific group, utilize [non-capture groups](https://www.regular-expressions.info/brackets.html).
---
### regexp_like
```aml
regexp_like(text, regex, [flags: _flags])
```
**Description**
Checks if a text string matches a regular expression pattern.
**Return type**
Truefalse
**Examples**
```aml title="Check if a string contains a number"
regexp_like('Product123', '[0-9]+') // Returns true
```
```aml title="Check if a string starts with 'hello' (case insensitive)"
regexp_like('Hello World', '^hello', flags: 'i') // Returns true
```
**Parameters**
- `text`: The text string to search within
- `regex`: The regular expression pattern to match (exact regex syntax depends on the database)
- `flags` (optional): Flags to modify the behavior of the regular expression matching (supported flags depend on the database)
**Notes**
This function is not supported in the following databases:
- SQL Server
---
### regexp_replace
```aml
regexp_replace(text, regex, substitute, [flags: _flags])
```
**Description**
Replaces substrings in a text that match a regular expression pattern with a specified replacement text.
**Return type**
Text
**Examples**
```aml title="Replace all numbers in a text with 'X'"
regexp_replace('Product123', '[0-9]+', 'X') // Returns 'ProductX'
```
```aml title="Remove redundant whitespace from a text"
regexp_replace('Hello World', '\\s+', ' ') // Returns 'Hello World'
```
```aml title="Swap the first and last name"
regexp_replace('John Doe', '(\\w+) (\\w+)', '\\2, \\1') // Returns 'Doe, John'
```
**Parameters**
- `text`: The text string to perform the replacement in
- `regex`: The regular expression pattern to match (exact regex syntax depends on the database)
- `substitute`: The replacement string. You can use backreferences like `\\1` or `$1` (depending on the specific database) to refer to captured groups in the regex
- `flags` (optional): Flags to modify the behavior of the regular expression matching (supported flags depend on the database)
**Notes**
This function is not supported in the following databases:
- SQL Server
---
### replace
```aml
replace(text, old_substring, new_substring)
```
**Description**
Replaces all occurrences of a substring within a text string with a new substring.
**Return type**
Text
**Examples**
```aml title="Replace 'World' with 'Universe' in 'Hello World'"
replace('Hello World', 'World', 'Universe') // Returns 'Hello Universe'
```
```aml title="Replace all 'o' with '0' in 'Hello World'"
replace('Hello World', 'o', '0') // Returns 'Hell0 W0rld'
```
---
### split_part
```aml
split_part(text, delimiter, part_number)
```
**Description**
Splits a text string into parts based on a delimiter and returns the specified part.
`part_number` is the number of the part to return, starting from 1. It must be a positive number.
**Return type**
Text
**Examples**
```aml title="Get the second part of 'apple,banana,cherry' split by ','"
split_part('apple,banana,cherry', ',', 2) // Returns 'banana'
```
```aml title="Get the first part of 'Hello World' split by ' '"
split_part('Hello World', ' ', 1) // Returns 'Hello'
```
**Notes**
This function is not supported in the following databases:
- Microsoft SQL Server
---
## Time Intelligence Functions(Aql)
---
### epoch
```aml
epoch(date)
epoch(datetime)
```
**Description**
`epoch` returns a Unix timestamp which is the number of seconds that have elapsed since ‘1970-01-01 00:00:00’ UTC.
You can use this function to return a Unix timestamp based on the current date/datetime or another specified date/datetime.
**Return type**
Number
**Example**
Given an AQL expression as below:
```aml
epoch(orders.created_at)
```
The result would be:
| created_at | epoch(created_at) |
| ------------------- | ----------------- |
| 2018-06-12 09:26:49 | 1528795609 |
| 2018-06-12 | 1528761600 |
---
### date_trunc
```aml
date_trunc(datetime, datetime_part)
```
Let's say that `orders.created_at` is `2021-05-28 10:30:39`
| Function | Result |
|-----------------------------------------|----------------------|
| `orders.created_at` | 2021-05-28 10:30:39 |
| `date_trunc(orders.created_at, 'day')` | 2021-05-28 00:00:00 |
| `date_trunc(orders.created_at, 'month')` | 2021-05-01 00:00:00 |
| `date_trunc(orders.created_at, 'year')` | 2021-01-01 00:00:00 |
| `date_trunc(orders.created_at, 'quarter')` | 2021-04-01 00:00:00 |
| `date_trunc(orders.created_at, 'week')` | 2021-05-24 00:00:00 |
| `date_trunc(orders.created_at, 'hour')` | 2021-05-28 10:00:00 |
| `date_trunc(orders.created_at, 'minute')` | 2021-05-28 10:30:00 |
```aml title="Examples"
// orders.created_at -> 2021-05-28 10:30:39
date_trunc(orders.created_at, 'day') // -> 2021-05-28 00:00:00
date_trunc(orders.created_at, 'month') // -> 2021-05-01 00:00:00
date_trunc(orders.created_at, 'year') // -> 2021-01-01 00:00:00
date_trunc(orders.created_at, 'quarter') // -> 2021-04-01 00:00:00
date_trunc(orders.created_at, 'week') // -> 2021-05-24 00:00:00
date_trunc(orders.created_at, 'hour') // -> 2021-05-28 10:00:00
date_trunc(orders.created_at, 'minute') // -> 2021-05-28 10:30:00
```
**Description**
Truncates a `date`/`datetime` value to the granularity of `datetime_part`. The `datetime` value is rounded to the beginning of `datetime_part`. The supported parts are:
- `'day'`: The day in the Gregorian calendar year that contains the `datetime` value.
- `'week'`: The first day of the week in the week that contains the `datetime` value. Weeks begin on the day that was set in` your [Week Start Day Setting](/docs/datetimes/week-start-day)
- `'month'`: The first day of the month in the month that contains the `datetime` value.
- `'quarter'`: The first day of the quarter in the quarter that contains the `datetime` value.
- `'year'`: The first day of the year in the year that contains the `datetime` value.
- `'hour'`: The hour in the day that contains the `datetime` value.
- `'minute'`: The minute in the hour that contains the `datetime` value.
:::tip
All date part has a corresponding short-hand truncate function. Examples below will use the short-hand version.
- `day(orders.created_at)` or `orders.created_at | day()`
- `month(orders.created_at)` or `orders.created_at | month()`
- `year(orders.created_at)` or `orders.created_at | year()`
- `quarter(orders.created_at)` or `orders.created_at | quarter()`
- `week(orders.created_at)` or `orders.created_at | week()`
- `hour(orders.created_at)` or `orders.created_at | hour()`
- `minute(orders.created_at)` or `orders.created_at | minute()`
:::
**Return type**
`date` or `datetime` depending on the input type.
### date_part
**Syntax**
```aml
date_part(datetime_part, datetime)
```
**Description**
The `date_part` function extracts a specific numeric part from a date or datetime value. It returns the numeric representation of the specified part of the date.
**Examples**
Let's say `orders.created_at` is `2021-05-28 10:30:39`
| Function | Result |
|-----------------------------------------|--------|
| `date_part('year', orders.created_at)` | 2021 |
| `date_part('quarter', orders.created_at)` | 2 |
| `date_part('month', orders.created_at)` | 5 |
| `date_part('week', orders.created_at)` | 22 |
| `date_part('dayofweek', orders.created_at)` | 5 |
| `date_part('dow', orders.created_at)` | 5 |
| `date_part('day', orders.created_at)` | 28 |
| `date_part('hour', orders.created_at)` | 10 |
| `date_part('minute', orders.created_at)` | 30 |
| `date_part('second', orders.created_at)` | 39 |
```aml title="Code Examples"
// orders.created_at -> 2021-05-28 10:30:39
date_part('year', orders.created_at) // -> 2021
date_part('quarter', orders.created_at) // -> 2
date_part('month', orders.created_at) // -> 5
date_part('week', orders.created_at) // -> 21
date_part('dayofweek', orders.created_at) // -> 4
date_part('dow', orders.created_at) // -> 4
date_part('day', orders.created_at) // -> 28
date_part('hour', orders.created_at) // -> 10
date_part('minute', orders.created_at) // -> 30
date_part('second', orders.created_at) // -> 39
```
**Supported Date Parts**
- `'year'`: Returns the numeric year (e.g., 2021)
- `'quarter'`: Returns the quarter number (1-4)
- `'month'`: Returns the month number (1-12)
- `'week'`: Returns the week number of the year (1-53)
- `'dayofweek'` or `'dow'`: Returns the day of the week number (0-6)
- `'day'`: Returns the day of the month (1-31)
- `'hour'`: Returns the hour of the day (0-23)
- `'minute'`: Returns the minute of the hour (0-59)
- `'second'`: Returns the second of the minute (0-59)
:::tip
**Shorthand Functions**
Shorthand functions with `_num` suffix are available for quick access:
- `year_num(orders.created_at)` or `orders.created_at | year_num()`
- `quarter_num(orders.created_at)` or `orders.created_at | quarter_num()`
- `month_num(orders.created_at)` or `orders.created_at | month_num()`
- `week_num(orders.created_at)` or `orders.created_at | week_num()`
- `dayofweek_num(orders.created_at)` or `orders.created_at | dayofweek_num()`
- `dow_num(orders.created_at)` or `orders.created_at | dow_num()`
- `day_num(orders.created_at)` or `orders.created_at | day_num()`
- `hour_num(orders.created_at)` or `orders.created_at | hour_num()`
- `minute_num(orders.created_at)` or `orders.created_at | minute_num()`
- `second_num(orders.created_at)` or `orders.created_at | second_num()`
:::
**Additional Notes**
- The function returns an integer representing the specified part of the date
- Day of week use 0-based indices
- Week numbering follows the ISO 8601 standard
- Day of week numbering depends on your organization settings
**Return type**
Number
### date_diff
```aml
date_diff(datetime_part, start, end)
```
```aml title="Examples"
date_diff('day', orders.created_at, @now)
date_diff('month', orders.created_at, @now)
```
**Description**
Calculates the difference between two dates in the specified `datetime_part`. The supported parts are:
- `'day'`: The number of days between the two dates.
- `'week'`: The number of weeks between the two dates.
- `'month'`: The number of months between the two dates.
- `'quarter'`: The number of quarters between the two dates.
- `'year'`: The number of years between the two dates.
**Return type**
Number
---
### date_format
```aml
date_format(datetime, format)
```
**Description**
Formats a date according to the specified format string.
**Return type**
Text
**Examples**
```aml title="Format a date"
date_format(orders.created_at, '%Y-%m-%d')
```
```aml title="Format a date with time"
date_format(orders.created_at, '%Y-%m-%d %H:%M:%S')
```
```aml title="Format a date with month name"
date_format(orders.created_at, '%B %d, %Y')
```
```aml title="Format a date with day of the week"
date_format(orders.created_at, '%A, %B %d, %Y')
```
**Format Patterns**
| Pattern | Description | Example |
| :------ | :-------------------------------------------- | :--------------------- |
| `%Y` | Four-digit year | `2018` |
| `%y` | Two-digit year | `18` |
| `%q` | Quarter of the year (1–4) | `3` |
| `%m` | Two-digit month | `07` |
| `%B` | Full month name | `July` |
| `%b` | Abbreviated month name | `Jul` |
| `%A` | Full day of week | `Sunday` |
| `%a` | Abbreviated day of week | `Sun` |
| `%d` | Two-digit day of month (01-31) | `08` |
| `%H` | Two-digit hour based on 24-hour clock (00–23) | `00` |
| `%I` | Two-digit hour based on 12-hour clock (01–12) | `12` |
| `%M` | Two-digit minutes (00–59) | `34` |
| `%S` | Two-digit seconds (00–59) | `59` |
| `%p` | AM or PM | `AM` |
| `%L` | Three-digit milliseconds (000–999) | `000` |
| `%f` | Six-digit microseconds (000000–999999) | `000000` |
| `%%` | The percent sign | `%` |
:::note
Not all format patterns are supported by all databases.
:::
---
### from_unixtime
```aml
from_unixtime(number)
```
**Description**
Converts a Unix timestamp (seconds since epoch) to a datetime value.
**Return type**
Datetime
**Example**
```aml
from_unixtime(1528795609)
```
---
### last_day
```aml
last_day(datetime, date_part)
```
**Description**
Returns the last day of the period for a given date.
**Return type**
Date
**Examples**
```aml title="Get the last day of the month"
last_day(orders.created_at, 'month')
```
```aml title="Get the last day of the quarter"
last_day(orders.created_at, 'quarter')
```
**Supported Date Parts**
- `'month'`: The last day of the month
- `'year'`: The last day of the year
- `'quarter'`: The last day of the quarter
- `'week'`: The last day of the week
---
## top
## Definition
Find the top N values of a [dimension](/reference/aql/type-dimension), based on specified measures. The top rows are determined by the specified measures, in descending order. Default to use 'skip' logic, which is the same as the [rank](/reference/aql/rank) function. To use 'dense' logic, use the `logic: 'dense'` parameter.
**Syntax**
```aml
top(n, dimension, by: measure_expr, ...)
top(n, dimension, by: measure_expr, ..., logic: logic)
```
```aml title="Examples"
top(10, users.name, by: count(orders.id)) // -> Table(users.name)
top(10, users.name, by: count(orders.id), by: average(users.age)) // -> Table(users.name)
top(10, users.name, by: count(orders.id), logic: 'dense') // -> Table(users.name)
```
**Input**
- `n` (**required**): The number of top values to return.
- `dimension` (**required**): A fully-qualified reference to a dimension. The output table will have one row for each top value of the specified dimension.
- `by` (**repeatable**, **required**): A measure that is used for ranking. The top rows are determined by the specified measure, in descending order. E.g. `top(10, users.name, by: count(orders.id))`
- `logic` (**optional**): The logic to use for ranking. Default to **'skip'**. To use 'dense' logic, use `logic: 'dense'`. E.g. `top(10, users.name, by: count(orders.id), logic: 'dense')`
**Output**
A new table with one row for each top value of the specified dimension.
## Sample Usages
Notice that the their are 6 users in the top 5 because there are users with the same number of orders.
Using dense rank logic would return even more users because their rank is consecutive. E.g. The users below the 2 top-1 users would be ranked 2 instead of skipping to 3.
Top is especially useful when used with [where](/reference/aql/where) to filter measure to only include the top values.
You can also use it for nested aggregation, such as finding the top 5 users by number of orders, then finding the average age of those users. (Note: `orders.value` is a measure)
## See also
- [`bottom()`](/reference/aql/bottom)
- [`group()`](/reference/aql/group)
- [`rank()`](/reference/aql/rank)
- [Pipe operator](/reference/aql/operator#pipe)
---
## trailing_period
## Definition
Calculates a metric over a specific number of date periods up to the current period (in the context of the current row, not the current month/period on your calendar). For example, you can apply this computation to determine the total orders you have in the last 3 months up until the current month, also known as Trailing 3 Months Metric.
**Syntax**
```aml
trailing_period(metric, date_dimension, period)
```
```aml title="Examples"
// Return total orders in the last 3 months using interval
trailing_period(count(orders.id), orders.created_at, interval(3 months))
// Return total orders in the last 3 periods using number (requires time grain)
trailing_period(count(orders.id), orders.created_at, 3)
// with pipe
count(orders.id) | trailing_period(orders.created_at, interval(3 months))
count(orders.id) | trailing_period(orders.created_at, 3)
```
**Input**
- `metric` (**required**): The metric on which you want to apply the `trailing_period()` function
- `date_dimension` (**required**): The date dimension that is used to determine the periods
- `period` (**required**): Either:
- An interval literal that specifies the number of periods to calculate (includes the current period). E.g. `interval(3 months)`, `interval(1 year)`.
- A number that specifies how many periods to include based on the current time grain. E.g. `3` for 3 months when grouped by month, or 3 days when grouped by day. **Note: When using a number, the date dimension must have a time grain applied (e.g., `| month()`, `| day()`).**
**Output**
A metric that calculates input `metric` over a specific number of date periods up to the current period.
## Number-based Period Example
When using a number instead of an interval, the period is determined by the time grain applied to the date dimension:
```aml
explore {
dimensions {
order_items.created_at | month() // Time grain is month
}
measures {
this_month_count: count(order_items.order_id),
last_3_months_count: count(order_items.order_id) | trailing_period(order_items.created_at, 3) // Last 3 months
}
}
```
In this example:
- Since the dimension is grouped by month (`| month()`), the number `3` means "last 3 months"
- If the dimension was grouped by day (`| day()`), the number `3` would mean "last 3 days"
- If no time grain is specified, an error will be raised
## Sample Usages
Trailing periods offer a valuable tool for analyzing how metrics perform over a rolling or moving time frame. Imagine that 3 months is a good time frame to see how a certain metric performs in your business. By using `trailing_period` you can see how a metric historically performed for the past 3 months at any given point in time. This is especially useful for metrics that represent a ratio or percentage, such as the Percentage of Cancelled Orders, Conversion Rate, etc.
For example, here's how you can use `trailing_period` to calculate how the **Past 3 Months Percentage of Cancelled Orders** changes over time:
Note that, this is **not the same as the average of the percentage** of cancelled orders over the last 3 months, but rather the **same percentage metric** calculated on a **different time frame**. In this case, when Percentage of Cancelled Orders is defined as:
```
Cancelled % = Count Cancelled Orders / Count Orders
```
Then the Past 3 Months Percentage of Cancelled Orders is calculated as:
$$
\text{Past 3 Months Cancelled \%} = \frac{\text{Count Cancelled Orders in the last 3 months}} {\text{Count Orders in the last 3 months}}
$$
## Frequently Asked Questions
### What happens if the visualization date grain is finer than the period specified in trailing_period?
In that case, `trailing period` will calculate the metric over this time frame:
$$
\text{Current Period} - \text{N Period} < \text{Date} \leq \text{Current Period}
$$
Examples:
- Trailing 3 Months Metric displayed daily.
- Trailing 1 Year Metric displayed monthly.
### What happens if the visualization date grain is coarser than the period specified in trailing_period or if the visualization has no date dimension at all?
In that case, `trailing_period` will calculate the metric over the last N periods, where N is the number of periods specified in trailing_period.
For example:
- For a Trailing 3 Months Metric displayed yearly, the metric for 2022 will be calculated from October 1st, 2019, to December 31st, 2022 (assuming the last record in 2022 is in December 2022).
- For a Trailing 3 Months Metric displayed as a KPI Metric (a single number), the metric will be calculated from October 1st, 2024, to December 31st, 2024 (assuming the last date in the dataset is in December 2022).
## See also
- [`exact_period()`](/reference/aql/exact_period)
- [`relative_period()`](/reference/aql/relative_period)
- [`running_total()`](/reference/aql/running_total)
- [Time Comparisons](/as-code/aql/learn/time-comparisons)
---
## Date
## Definition
This type is commonly used to represent dates in various applications and systems. For example, it can be used to represent the date when an event occurred, the date when a document was created, or the date when a payment was made. The "Date" type does not include any time information, and is typically used to represent dates in the local time zone of the system or application where it is being used.
```aml
dimension user_birth_date {
label: "User Birth Date"
type: "date"
}
```
## Date Operator
Operator
Example
Meaning
Description
==
- orders.created_at == @2022
- orders.created_at == @(last 7 days)
- orders.created_at equal to 2022-01-01 00:00:00- orders_created_at equal to the first timestamp of the last 7 days
Include data that equal to an absolute timestamp
ismatchesmatch
- orders.created_at is @2022
- orders.created_at match @(last 7 days)
- orders.created_at is in the period of the year 2022- order.created_at is in the period of the last 7 days
Include data that are in a time period
!=
orders.created_at != @2022-01
- orders.created_at is not equal to 2022-01-01 00:00:00
Include data that do not equal to an absolute timestamp
is not
orders.created_at is not @2022-01
- orders.created_at is not in the period of 2022-01
Include data that are not in a time period
>
orders.created_at > @(yesterday)
- orders.created_at is after yesterday
Include data that are after a specific time period
<
orders.created_at < @2022
- orders.created_at is before the year 2022
Include data that are before a specific time period
## Date Format
For date formating in the dimension definition, please refer to [AML Date Format](/reference/aml/date-format)
---
## Datetime
## Definition
A datetime value, represented as a string in the format "YYYY-MM-DD HH:MM:SS". The "datetime" type includes both date and time information, and is typically used to represent timestamps in the local time zone of the system or application where it is being used. It also has built-in time zone handling, which allows it to handle time zone conversions and daylight saving time adjustments automatically.
```aml
dimension order_shipped_at {
label: "Order Shipped At"
type: "datetime"
}
```
## Datetime Operator
Operator
Example
Meaning
Description
==
- orders.created_at == @2022
- orders.created_at == @(last 7 days)
- orders.created_at equal to 2022-01-01 00:00:00- orders_created_at equal to the first timestamp of the last 7 days
Include data that equal to an absolute timestamp
ismatchesmatch
- orders.created_at is @2022
- orders.created_at match @(last 7 days)
- orders.created_at is in the period of the year 2022- order.created_at is in the period of the last 7 days
Include data that are in a time period
!=
orders.created_at != @2022-01
- orders.created_at is not equal to 2022-01-01 00:00:00
Include data that do not equal to an absolute timestamp
is not
orders.created_at is not @2022-01
- orders.created_at is not in the period of 2022-01
Include data that are not in a time period
>
orders.created_at > @(yesterday)
- orders.created_at is after yesterday
Include data that are after a specific time period
<
orders.created_at < @2022
- orders.created_at is before the year 2022
Include data that are before a specific time period
---
## Dimension (reference)
## Definition
Some functions in AQL require the input to be a reference to a predefined [dimension](/reference/aml/field#dimension) in AML. This means these functions cannot accept arbitrary expressions but only accept AQL in the form of `model.field_name`.
:::info
`date_trunc` and its short-hand functions (`year`, `month`, `day` etc) are the "exceptions" to this. You can think that they accept a reference to a time dimension and return a reference to a derived time-dimension.
For example, it's perfectly valid to group by `users.created_at | year()` or `date_trunc(users.created_at, 'year')`, as these expressions technically return dimension references rather than arbitrary values.
:::
For example, suppose we have the following `users` model:
```aml
Model users {
dimension id {}
dimension first_name {}
dimension last_name {}
dimension email {}
dimension delivered_orders {}
dimension cancelled_orders {}
}
```
When we want to group by the full name, we can't do this:
```aml
users | group(concat(users.first_name, " ", users.last_name)) | select(count(users.id))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Error: `group` expects `Dimension` but got `Text`
```
This happens because [group](/reference/aql/group) only accepts references to dimensions, not arbitrary expressions.
To fix this issue, you must define a `users.full_name` field to pass into `group`:
```aml
dimension full_name {
definition: @aql concat(users.first_name, " ", users.last_name) ;;
}
```
Then you can use it like this:
```aml
users | group(users.full_name) | select(count(users.id))
```
---
## Field Type
:::tip Knowledge Checkpoint
This documentation assumes you are familiar with the following concepts:
- [Table Type](/reference/aql/type-table)
- [Origin Model](/as-code/amql/aql-concepts-origin)
:::
### Definition
In AQL, a `Field` object is a part of a `Table` object, and it contains additional information that allows AQL to correctly transform your data. For example:
- Field origin: Dimension(orders.status)
- Field’s data type: text, number, truefalse…
In the following section, we will dive deeper into the Field Origin concept.
### Field Origin
Field Origin is an important feature that allows AML to correctly query data and perform aggregations.
The field’s origin consists of three parts:
- **Field class:** Dimension or Measure / Metric
- **The origin model:** the AML model where the field was defined
- **The field’s name** in the AML layer
### Field Origin vs. Table Origin
Unlike Table’s Origin, the Field origin is often not retained after transformation. In other words, most transformation functions when applied on fields will return a **new field without the origin**.
:::caution
Most transformation functions do not preserve the field’s origin
:::
For example, suppose we have the following `users` model:
```aml
Model users {
dimension id {}
dimension first_name {}
dimension last_name {}
dimension email {}
dimension delivered_orders {}
dimension cancelled_orders {}
}
```
When writing an exploration from the `users` model, we create a new field called `total_orders`:
```aml
users
| select(
email,
delivered_orders,
cancelled_orders,
total_orders: users.delivered_orders + users.cancelled_orders
)
```
Will produce a new Table object with the following fields:
Table (origin: users)
Field
Data Type
Origin
email
Text
Dimension(users.email)
delivered_orders
Number
Dimension(users.delivered_orders)
cancelled_orders
Number
Dimension(users.cancelled_orders)
total_orders
Number
None
This new `total_orders` field is a new field **without origin**. This field can still be used in later calculations, but it will not be treated as a Dimension or Measure field, and cannot be input of some functions.
:::caution
Fields without origin cannot be used as Dimensions.
:::
In other words, the following expression will be valid:
```aml
users
| select(
email,
delivered_orders,
cancelled_orders,
total_orders: users.delivered_orders + users.cancelled_orders
)
| select(
email,
cancellation_rate: cancelled_orders / total_orders
)
```
And it will produce a new table:
Table (origin: users)
Field
Data Type
Origin
email
Text
Dimension(users.email)
cancellation_rate
Number
None
However, you cannot use this `total_orders` field as a dimension to group and then aggregate:
```aml
// Count number of users grouped by number of total orders
// However, this is an invalid expression
users
| select(
email,
delivered_orders,
cancelled_orders,
total_orders: users.delivered_orders + users.cancelled_orders
)
| group(total_orders) // group() only accept Dimensions
| count(users.email)
```
---
## Types
**Type** can be understood as the “blueprint”, or the “category” of an object in AQL. Depending on its type, an object can have certain properties.
For example, considering the following model:
```aml
Model orders {
dimension id {type: 'number'}
dimension total_value {type: 'number'}
dimension status {type: 'text'}
measure orders_count {
type: 'number'
description: 'Count all orders, regardless of status'
definition: @aql count(orders.id) ;;
}
}
```
- `orders` is an object of type `Model`
- `total_value` is an object of type `Field`, with the following properties:
- Field class: Dimension
- Origin model: `orders`
- Data type: `number`
- `orders_count` is an object of type `Field`, with the following properties:
- Field class: Measure
- Origin model: `orders`
- Data type: `number`
In this section of the docs, you will find detailed information about different types available in AQL.
---
## Measure
Measure syntax defines the measure of a data model. Measure represents an aggregated dimension in a model.
## Parameter
Parameter name | Description
-------------- | ------------
label | Specifies how the measure will appear in the Ready-to-explore Dataset
type | Specifies the data type you want to apply to the measure (currently Holistics only support number/date/datetime)
description | Add measure description
hidden | Hides measure from the Exploration interface of Dataset and Report
aggregation_type | Specify aggregate function of Measure. Currently we only support: **count, count distinct, sum, avg, max, min, median, stdev (sample standard deviation), stdevp (population standard deviation), var (sample variance), varp (population variance), custom (custom sql aggregation)**
definition | Determines how the measure will be defined or calculated based on SQL queries. Learn more about the definition parameter below. 👇
## SQL Definition of Measure
**Forms:** There are two primary forms that definition for measures can take:
1. **Native Holistics Aggregation Type**: Use aggregation type that Holistics natively supports such as sum, count, count_distinct, avg, etc.. For example:
```aml
measure total_users {
label: 'Total Users'
type: 'number'
// The definition here is the inner expression of the aggregation
definition: @sql {{ user_id }};;
aggregation_type: 'count'
}
```
When used in an explore, this measure will be treated as `COUNT({{ user_id }})`
2. **Custom Aggregation Form** (`aggregation_type: 'custom'` - this is the default when aggregation_type is not specified): The entire definition is used as the aggregation expression. This allows you to use aggregation functions from the source database that are not supported by Holistics (e.g. PERCENTILE_CONT from Redshift).
```aml
measure percentile {
label: 'percentile'
type: 'number'
// must be valid aggregation expression that can be run in
// aggregation position of a query
definition: @sql percentile_cont(0.6) within group (order by {{ profit }});;
aggregation_type: 'custom'
}
```
```aml
SELECT
col,
-- ...other group by columns
COUNT(*) -- The definition of custom aggregation must be an expression
-- that can be placed here
FROM orders
GROUP BY
1
-- , ...other group by columns
```
Additionally, you can write a custom measure with calculations between measures:
```aml
measure profit {
label: 'Profit'
type: 'number'
// must be valid aggregation expression that can be run in
// aggregation position of a query
definition: @sql {{ measure_revenue }} - {{ measure_cost }} + sum({{ dimension_discount }});;
aggregation_type: 'custom'
}
```
However, it's important to note that you **cannot** directly use dimensions without aggregation in a custom measure. For example:
```aml
measure profit {
label: 'Profit'
type: 'number'
// top level must be aggregated
definition: @sql {{ measure_revenue }} - {{ measure_cost }} + {{ dimension_discount }};;
aggregation_type: 'custom'
}
```
## Examples
```aml
Model users {
type: 'table'
label: "Users"
description: "This is the AML Users Model"
table_name: '"ecommerce"."users"'
data_source_name: 'demodb'
measure total_users {
label: 'Total Users'
type: 'number'
definition: @sql count({{#SOURCE.id}});;
aggregation_type: 'custom'
}
}
```
## FAQs
### How should I define aggregate functions for measures, and what are the important considerations?
- Choose between using `aggregation_type` parameter or using aggregation functions from the database within `definition: @sql ;;` parameter. It’s important to note that you **should not** define an aggregate function in both parameters.
- If you define an aggregate function using the `aggregation_type`, the `definition: @sql ;;` parameter **must not contain** any aggregate functions.
```aml
//What you should write:
measure measure_1 {
...
definition: @sql {{ user_id }};;
aggregation_type: 'count'
}
---------------------------
//What you should NOT write
measure measure_1 {
...
definition: @sql count{{ user_id }};;
aggregation_type: 'count'
}
```
- If you define the aggregate function within the `definition: @sql ;;` parameter, make sure to set the `aggregation_type` to `custom`.
```aml
//What you should write:
measure measure_2 {
...
definition: @sql sum({{#SOURCE.id}});;
aggregation_type: 'custom'
}
---
## Number
## Definition
A numeric value, represented as an numeric, integer, float, or double. It can be used as dimension or measure type. The `number` type can include positive and negative values, as well as decimal points.
```aml
dimension order_value {
label: "Order Value"
type: "number"
}
measure revenue {
label: "Revenue"
type: "number"
}
```
## Number Operator
| Operator | Example | Description |
| --- | --- | --- |
| `==` `is`| `order_items.discount == 0.5` `order_items.discount is 0.5`| Equal to|
| `!=` `is not`| `order_items.discount != 1` `order_items.discount is not 1`| Not equal to|
| `>` | `order_items.discount > 0.5` | Greater than |
| `<` | `order_items.discount < 0.5` | Less than |
| `is null` | `order_items.discount is null` | Include if null |
| `not null` | `order_items.discount not null` | Include if not null |
## Number Format
For the number formating in the dimension/measure defintion, please refer to [AML Number Format](/reference/aml/number-format)
---
## Scalar Type
Scalar types are AQL’s generic equivalent of SQL predefined types (types that can be defined as the type of a column). All the currently fully supported scalar types are:
| Type | Description |
| --- | --- |
| Date | Date value like 2022-01-02 |
| Datetime | Whole datetime value like 2022-01-02 20:10:00 with built-in time zone handling |
| Number | Numeric, Float, Double, and Integer all fall under this category |
| Text | A string of characters. |
| Truefalse | A boolean value, represented as either "true" or "false". |
The following scalar types, while they exist cannot be used in any way, since we don’t support any operations on them:
| Type | Description |
| --- | --- |
| Time | A point in time, represented as a timestamp or a time string |
| Duration | A length of time, represented as a duration string |
| Composite | A combination of multiple scalar types |
| Binary | A binary data, represented as a sequence of bytes |
| Unknown | A scalar type that cannot be identified or is not yet supported |
---
## Table Type
:::tip Knowledge Checkpoint
This documentation assumes you are somewhat familiar with the following concepts:
- [Types](/as-code/amql/aql-basic-concepts#types)
- [Model](/as-code/amql/aql-basic-concepts#model)
- [Origin Model](/as-code/amql/aql-basic-concepts#tables-origin-model)
:::
In AQL, a `Table` is an object that organizes data in the database in rows and columns. An AQL Table is similar to an SQL table, but it has extra properties that allow users to query and transform data flexibly.
### A Table is the result of querying a Model
Suppose that you have defined an `orders` model in the AML layer:
```aml
Model orders {
dimension id {}
dimension status {}
dimension value {}
}
```
When writing AQL code, if you refer to the model `orders`:
```aml
orders
```
This expression will return a **`Table` object** that contains the fields as defined in the model, and all the order data in the database.
When you perform transformations like selection or filtering operations, you will also receive a **`Table` object**:
```aml
orders // <- a table
| select(orders.status, orders.user_id) // <- a table
| filter(orders.status in ['cancelled', 'finished']) // <- table
```
However, after aggregations, you may receive a Table or a Scalar value:
```aml
orders
| group(status)
| select(count: count(orders.id)) // <- a table with the column `count`
orders | count(orders.id) // <- a scalar value
```
---
## Text(Aql)
## Definition
A text value, represented as a string of characters. The "text" type can include any characters, including letters, numbers, symbols, and whitespace.
```aml
dimension product_name {
label: "Product Name"
type: "text"
}
```
## Text Operator
| Operator | Example | Description |
| --- | --- | --- |
| `==` `is`| `products.name == 'Dandelion'` `products.name is 'Dandelion'` | Equal to |
| `!=` `is not`| `products.name != 'Rock'` `products.name is not 'Rock'`| Not equal to |
| `like` | `products.name like '%Dan'` | Match the pattern specified |
| `not like` | `products.name not like '%Dan'` | Not match the pattern specified |
| `ilike` | `products.name ilike '%dan'` | Match the pattern specified, case insensitive |
| `not ilike` | `products.name not ilike '%dan'` | Not match the pattern specified, case insensitive |
| `is null` | `products.name is null` | Include if the value is null |
| `not null` | `products.name not null` | Include if the value is not null |
| `in` | `products.name in ['Dandelion', 'Rock']` | Include if the value is in the list |
| `not in` | `products.name not in ['Dandelion', 'Rock']` | Include if the value is not in the list |
---
## Truefalse
## Definition
A boolean value, represented as either "true" or "false". This is commonly used to represent logical values. For example, it can be used to represent the result of a comparison, the status of a switch, or the answer to a yes/no question. The "truefalse" type can only take on two values: "true" or "false".
```aml
dimension is_order_shipped {
label: "Is Shipped"
type: "truefalse"
}
```
## Truefalse Operator
| Operator | Example | Description |
| --- | --- | ---
| `is` | `orders.is_paid is true` | Equal to |
| `is_not` | `orders.is_paid is not true` | Not equal to|
| `is null` | `orders.is_paid is null` | Include if null |
| `not null` | `orders.is_paid not null` | Include if is not null |
---
## unique
## Definition
Return a new table with all unique combination of values in the specified dimensions. It is similar to [group](/reference/aql/group) but without the need to specify a source table. The difference is that [group](/reference/aql/group) will only return combination of values that exist with respect to the source table, while [unique](/reference/aql/unique) will choose the source table with the most number of rows.
**Syntax**
```aml
unique(dimension, dimension, ...)
```
```aml title="Examples"
unique(orders.id, products.id) // -> Table(orders.id, products.id)
unique(orders.id, products.id, customers.id) // -> Table(orders.id, products.id, customers.id)
```
**Input**
- `dimension` (**repeatable**): A fully-qualified reference to a dimension. The output table will have one row for each unique combination of values in the specified dimensions.
**Output**
A new table with one row for each unique combination of values in the specified dimensions.
## Sample Usages
Using [unique](/reference/aql/unique) with [select](/reference/aql/select) just like [group](/reference/aql/group):
## See also
- [`group()`](/reference/aql/group)
- [`select()`](/reference/aql/select)
- [Pipe operator](/reference/aql/operator#pipe)
---
## where vs. filter
:::tip Knowledge Checkpoint
This documentation assumes that you are familiar with the following functions:
- [where](/reference/aql/where)
- [filter](/reference/aql/filter)
:::
In AQL, we have the `where()` and the `filter()` functions that perform roughly the same function (apply a filtering condition onto an object), so you may wonder which one to use in a particular situation. In this document, we will explain when and where you should use which function.
### Quick comparison
A quick rule-of-thumb to follow when deciding between `where()` and `filter()`:
| Function | Target | Valid filtering condition | When to use |
| :------- | :--------------------- | :------------------------ | :---------- |
| where() | Measure | dimension operator value. E.g. orders.status == 'delivered', orders.created_at matches @(last 7 days)dimension in [value1, value2, ...]. E.g. orders.status in ['delivered', 'cancelled']dimension in table. E.g. orders.status in unique(orders.status)dimension operator measure. E.g. users.age > avg(users.age) | Apply filters to a Measure |
| filter() | Table | Any expression that returns [truefalse](/reference/aql/type-truefalse) and is valid in the context of the table row | Filter a Table
### Examples
Suppose that you have an Ecommerce dataset with the following models:
```aml
Model orders {
dimension id {}
dimension status {}
dimension country {}
dimension user_id {}
dimension value {}
}
```
In the examples below, we will demonstrate the difference on how `where()` and `filter()` are used.
#### Only `where()` can be used
Suppose you have defined the `total_orders` measure inside `orders` model:
```aml
Model orders {
...
measure total_orders {
definition: @aql count(orders.id)
}
}
```
Now you want to calculate **“the total orders which are delivered.”** You can use `where()` to apply filter to the measure:
```aml
orders.total_orders | where(orders.status == 'delivered') // valid expression
// This will error
orders.total_orders | filter(orders.status == 'delivered') // invalid expression
```
By definition, `filter()` cannot be used here.
#### Only `filter()` can be used
`filter()` can be used when you want to filter by arbitrary expression that haven't been defined as a dimension yet. For example, you want to know which country has at least 100,000 users who have placed at least 1 order:
```aml
orders
| group(orders.country)
| select(orders.country, users_count: count_distinct(orders.user_id))
// highlight-next-line
| filter(users_count >= 100000) // where here will be invalid
```
In this case, `where()` cannot be used in place of `filter()`, because by definition, `where()` have to filter on an existing dimension of `orders`, while the field `users_count` is an aggregation that has not been defined and calculated before.
---
## where
## Definition
Apply condition(s) to modify a measure/metric.
**Syntax**
```aml
where(metric, condition, ...)
metric | where(condition, ...)
```
```aml title="Examples"
where(count(orders.id), orders.status == 'delivered')
// with pipe
count(orders.id) | where(orders.status == 'delivered')
// multiple conditions
count(orders.id) | where(orders.status == 'delivered', orders.created_at matches @(last 7 days))
```
**Input**
- `metric`: An AQL metric expression.
- `condition` (**repeatable**): A condition that will be applied to the metric. See [Condition Types](#condition-types) for detailed information about different types of conditions.
:::info
If multiple `condition` are provided, they are evaluated as a logical AND. For example, `where(orders.status == refunded', orders.is_cancelled)` is equivalent to `where(and(orders.status == refunded', orders.is_cancelled))`.
:::
**Output**
New metric with filter applied
## Sample Usages
This expression below will evaluate and modify `count_orders` to only include those with `orders.status == 'delivered'`
`where()` can also be applied to a pre-defined metric
Using `where()` to create a semi-additive metric. For example, if you want to create a metric that only count orders that are created in the last day, you can use `where()` to apply the condition to the `count(orders.id)` metric:
## Condition Types
The `where()` function supports various types of conditions that can be categorized into simple and complex conditions.
### Simple Conditions
#### Constant Condition
A constant condition compares a dimension with a constant value using an operator.
**Format**: `dimension operator constant_value`
**How it works**: The condition targets the specific model that the dimension belongs to and filters it before the data enters the metric calculation. This pre-filtering ensures that only rows meeting the criteria are included in the aggregation.
**Examples**:
```aml
// Equality check
count(orders.id) | where(orders.status == 'delivered')
// Comparison operators
sum(orders.amount) | where(orders.amount > 100)
// Date matching
count(orders.id) | where(orders.created_at matches @(last 7 days))
// List inclusion
count(orders.id) | where(orders.status in ['delivered', 'cancelled'])
```
#### Single Model Condition
A single model condition uses fields from a single model to perform calculations or comparisons. This type of condition only works when the model defines a primary key.
**Format**: Expression using fields from a single model
**How it works**: Similar to constant conditions, this filters the model before metric calculation. The model must have a primary key because internally, the condition is converted to a table condition on the primary key. For example:
```aml
count(orders.id) | where(date_diff('day', orders.created_at, orders.delivered_at) < 30)
// is internally converted to:
count(orders.id) | where({orders.id} in
orders
| filter(date_diff('day', orders.created_at, orders.delivered_at) < 30)
| select(orders.id)
)
```
**Examples**:
```aml
// Date difference calculation
count(orders.id) | where(date_diff('day', orders.created_at, orders.delivered_at) < 30)
// Field comparison within same model
count(orders.id) | where(orders.discount_amount < orders.total_amount * 0.5)
// Complex calculations on single model
sum(orders.revenue) | where((orders.revenue / orders.quantity) > 50)
```
#### Cross Model Condition
A cross model condition involves fields from multiple models. This type of condition is converted to a table condition internally.
**Format**: Expression using fields from multiple models
**How it works**: When you use fields from multiple models in a condition, the system internally converts it to a table condition that matches on **all dimensions involved**. This creates a unique combination of all referenced dimensions and filters based on that combination.
**Important note**: Cross model conditions match on all dimensions involved, creating dimension combinations. This behavior might be exactly what you want in some cases, but in others you might need a different approach:
**When dimension combination matching is desired**:
If you want to analyze specific combinations (e.g., specific product-discount pairs), the default behavior works well.
**When you might want an alternative approach**:
If you want to match on specific dimensions rather than all dimensions involved, you might prefer using a table condition for more control.
**Examples**:
```aml
// Average quantity of items in cancelled orders with quantity > 3
avg(order_items.quantity) | where(order_items.quantity > 3 and orders.status == 'cancelled')
This is internally converted to:
{order_items.quantity, orders.status} in unique(order_items.quantity, orders.status)
| filter(order_items.quantity > 3 and orders.status == 'cancelled')
// Result: Averages ONLY items with quantity > 3
// Alternative: Average ALL items in cancelled orders that contain at least one item with qty > 3
avg(order_items.quantity) | where(order_items.order_id in
order_items
| filter(order_items.quantity > 3 and orders.status == 'cancelled')
| select(order_items.order_id)
)
// Result: Averages ALL items in qualifying orders
// Example difference:
// Order #1 (cancelled): items with quantities [2, 5, 7]
// - Cross-model approach: avg([5, 7]) = 6
// - Alternative approach: avg([2, 5, 7]) = 4.67
```
### Complex Conditions
#### Table Condition
A table condition checks if a dimension exists in a derived table.
**Format**:
- Explicit: `dimension in table_expression`
- Implicit: `table_expression` (internally converted to `{dimension} in table_expression`)
**How it works**: Table conditions provide fine-grained control by allowing you to filter based on whether a dimension value exists in a derived table. The table expression can include complex transformations, filters, and aggregations, giving you precise control over which values to include.
When using the implicit form (just a table expression), the system matches on the dimensions that are clear from the context.
**Examples**:
```aml
// Explicit table condition
count(orders.id) | where(orders.status in unique(orders.status) | where(orders.created_at > @2023-01-01))
// Using complex table expressions
sum(orders.amount) | where(
orders.user_id in users
| filter(users.country == 'US')
| select(users.id)
)
// Implicit table condition (just table expression)
count(users.id) | where(top(5, users.id, by: users.revenue))
// This is internally converted to:
// count(users.id) | where({users.id} in top(5, users.id, by: users.revenue))
```
#### Semi-additive Condition
A semi-additive condition compares a dimension with the result of a metric calculation. This is commonly used for [semi-additive calculations](/as-code/aql/cookbook/aql-semi-additive-calculation) like bank balances or inventory levels.
**Format**: `dimension operator metric`
**How it works**: The metric on the right side of the operator is calculated first, then each row is filtered based on whether the dimension value meets the comparison criteria. This enables powerful filtering patterns like "values greater than average" or "dates equal to the maximum date".
**Examples**:
```aml
// Compare with average
sum(users.revenue) | where(users.age > avg(users.age))
// Semi-additive: Last balance date
sum(balances.amount) | where(balances.date == max(balances.date))
// Compare with calculated metric
count(orders.id) | where(orders.amount > sum(orders.amount) / count(orders.id))
```
## When not to use `where()`?
Sometimes you want to filter an arbitrary expression that haven't been explicitly defined as a dimension yet. For example, you want to know which country has at least 100,000 users who have placed at least 1 order:
```aml
orders
| group(orders.country)
| select(orders.country, users_count: count_distinct(orders.user_id))
// This will error
| where(users_count >= 100000) // `where` here will be invalid
```
In this case, `where()` cannot be used, because by definition, `where()` have to filter on an existing dimension of `orders`, while the field `users_count` is an aggregation that has not been defined and calculated before. You can use `filter()` function instead.
```aml
orders
| group(orders.country)
| select(orders.country, users_count: count_distinct(orders.user_id))
// highlight-next-line
| filter(users_count >= 100000) // `filter` works here because it acts on table
```
## FAQ
### What is the difference between `where()` vs `filter()`
To understand when should you use `where()` or `filter()` to apply filtering condition to a transformation, please visit this document: [where vs filter](/reference/aql/where-vs-filter)
### I can't apply `where()` to a dimensionalized metric?
`where()` cannot be applied to a dimension. If you want to dimensionalize a metric (i.e turning a metric into a dimension), and you want to apply `where()` to the resulting dimension, **don't do this**:
```aml
// DON't do this
dimension dimensionalized_metric {
// ...
definition: @aql min(orders.created_at | month()) | dimensionalize(users.id);;
}
dimension another_dimension {
// ...
definition: @aql dimensionalized_metric | where(orders.country == 'Vietnam');;
}
```
If you do this, you won't get the desired result.
Instead, **do this**:
```
dimension dimensionalized_metric {
// ...
definition: @aql min(orders.created_at | month()) | where(orders.country == 'Vietnam') | dimensionalize(users.id);;
}
```
## See also
- [`filter()`](/reference/aql/filter): table-level analog
- [where vs filter](/reference/aql/where-vs-filter)
- [AQL Condition](/reference/aql/aql-condition)
- [Order of Operations](/as-code/aql/order-of-operations)
---
## Window functions
# Window Functions Overview
AQL window functions provide ways to do secondary calculations like navigating (previous, next), ranking, or aggregating (sum, avg, etc.) across a subset of rows of a table. These are analogous to SQL window functions.
## Overview
### Definition
Window functions are a class of functions that allow you to perform calculations across a set of table rows related to the [current row](/as-code/aql/learn/tables-and-rows) without changing the number of rows returned by the query, as opposed to [aggregate functions](/reference/aql/aggregator-functions), which reduce the number of rows returned by the query.
### Syntax
Most Window Functions in AQL have the following parameters:
- **`partition` (alias `reset`)** -> The column used to divide the table rows into multiple groups (partitions). You can use `'rows'`, `'columns'`, `'x_axis'`, or `'legend'` to partition by visualization axes.
- **`order`** -> The column used to order the rows within each partition. You can use `'rows'`, `'columns'`, `'x_axis'`, or `'legend'` to order by visualization axes.
- **`frame`** -> The window frame range, specifying the subset of rows related to the current row within the partition for calculations.
Each parameter corresponds to one of the three basic concepts of window functions: [partitioning](#partitioning), [ordering](#ordering), and [frame](#frame). These concepts will be explained in detail in the following sections.
For now, here are some examples of window functions in AQL:
1. The previous row's total orders, ordered by the month of the order creation date:
```aml
previous(orders.total_orders, order: orders.created_at | month())
```
2. The rank of the current row ordered by the total orders and partitioned by the order status:
```aml
rank(order: orders.total_orders, partition: orders.status)
```
3. The average of the count of orders, partitioned by `orders.status`, ordered by `orders.created_at`, and calculated in the frame containing the current row and the two rows before it:
```aml
window_avg(count(orders.id), -2..0, order: orders.created_at, partition: orders.status)
```
4. Calculating running totals across visualization axes:
```aml
// Running sum across rows
window_sum(orders.total_revenue, order: 'rows')
// Running sum across columns
window_sum(orders.total_revenue, order: 'columns')
// Using axis aliases for clarity
window_sum(orders.total_revenue, order: 'x_axis') // same as 'rows'
window_sum(orders.total_revenue, order: 'legend') // same as 'columns'
// Partition by one axis, order by another
window_sum(orders.total_revenue, order: 'rows', partition: 'columns')
// Override visualization sort order
window_sum(orders.total_revenue, order: 'rows' | desc())
```
## Types of Window Functions
### Navigation Functions
Navigation functions allow you to access values from other rows within the same partition. The available navigation functions in AQL are:
- [previous](/reference/aql/previous) - Accesses the value from the previous row
- [next](/reference/aql/next) - Accesses the value from the next row
- [first_value](/reference/aql/first_value) - Returns the value from the first row of the window frame
- [last_value](/reference/aql/last_value) - Returns the value from the last row of the window frame
- [nth_value](/reference/aql/nth_value) - Returns the value from the Nth row of the window frame
### Ranking Functions
Ranking functions assign a rank to each row based on the value of a specified column. The rank can be calculated in ascending or descending order. The available ranking functions in AQL are:
- [rank](/reference/aql/rank) - Calculates the (skip) rank of a value
- [dense_rank](/reference/aql/dense_rank) - Calculates the dense rank of a value
- [ntile](/reference/aql/ntile) - Divides rows into ranked groups
- [percent_rank](/reference/aql/percent_rank) - Calculates the relative percentile rank of a value
### Aggregate Functions
:::caution
Currently, Window Aggregate Functions only accept aggregate metrics as input. We plan to support non-aggregate input in the future.
:::
Aggregate functions calculate aggregate values across a subset of rows within a partition. These functions are similar to the aggregate functions in SQL but operate on a subset of rows within a partition. The aggregate functions available in AQL are:
- [window_avg](/reference/aql/window_avg)
- [window_sum](/reference/aql/window_sum)
- [window_min](/reference/aql/window_min)
- [window_max](/reference/aql/window_max)
- [window_count](/reference/aql/window_count)
- [window_stdev](/reference/aql/window_stdev)
- [window_stdevp](/reference/aql/window_stdevp)
- [window_var](/reference/aql/window_var)
- [window_varp](/reference/aql/window_varp)
## Basic Concepts
### Partitioning
Partitioning is the process of dividing the rows of a table into groups, each called a **partition**. If no partition is specified, the entire table is treated as a single partition. Partitioning is achieved by grouping rows that share the same value in the partitioning column.
The purpose of partitioning is to limit the scope of the window calculation so that each **row within a partition only "sees" the rows within the same partition**. Consider the following example:
```aml title="Total Orders by Month and Status Compared to Previous Month"
explore {
dimensions {
orders.created_at | month(),
orders.status,
}
measures {
total_orders: count(orders.id),
previous: previous(
count(orders.id),
order: orders.created_at | month(),
partition: orders.status,
)
}
}
```
In this example, for the line of Cancelled Orders in April, even though there are earlier months (e.g., February) with different statuses, they are not included because they belong to a different partition.
### Ordering
Ordering in window functions refers to the process of sorting rows within each partition. It's important to note that window ordering is distinct from the order of the final result set; window ordering does not influence the final arrangement of the results.
We can conceptualize it using this simplified mental model: the original table is duplicated and then sorted within each partition. Each row in the original table uses this internally sorted version to perform its calculations.
For instance, even if the rows in the table are not sorted by any specific criteria, the calculation of a previous row's total orders still accurately references the corresponding row in the sorted table.
#### Axis-Aware Ordering
When working with visualizations, you can use axes as ordering criteria:
- `'rows'` or `'x_axis'`: Orders by the dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Orders by the dimensions mapped to columns/legend
These axis references automatically adapt to your visualization structure and respect the sort order defined in your explore. You can also override the sort direction:
```aml
// Use the row sort order from the visualization
window_sum(revenue, order: 'rows')
// Override to sort rows in descending order
window_sum(revenue, order: 'rows' | desc())
// Override to sort rows in ascending order (useful when explore sorts desc)
window_sum(revenue, order: 'rows' | asc())
```
Note: If an axis is not available in certain chart types, it will be ignored. For example, in a simple bar chart, the `'columns'` axis may not be present.
### Frame
**Frames** in window functions refer to the range of rows within a partition that are considered for the window calculation. Think about the frame as a "window frame" that moves along the sorted partition relative to the current row.
In AQL, the frame is specified using a range literal, which consists of two integers separated by two dots (`..`). These integers represent the number of rows before and after the current row, respectively. For example, `-2..0` indicates the two rows before the current row and the current row itself.
Unbounded frames are also supported by omitting one of the integers. For instance:
- `0..` means from the current row to the last row in the partition.
- `..0` means from the first row in the partition to the current row.
- `..` means all rows in the partition.
Frames are available only to Aggregate Window Functions, such as `window_avg`, `window_sum`, `window_min`, and `window_max`, since they aren't useful for other types of window functions.
### Axis-Aware Window Functions
Window functions support using visualization axes directly in the `order` and `partition` parameters. This makes it easier to write calculations that adapt to your visualization structure across different chart types.
#### Available Axes
- `'rows'` or `'x_axis'`: Refers to dimensions mapped to rows or x-axis
- `'columns'` or `'legend'`: Refers to dimensions mapped to columns or legend/series
#### Examples
```aml title="Running Totals Across Axes"
explore {
dimensions {
rows {
_year: orders.created_at | year()
}
columns {
_category: products.category
}
}
measures {
revenue: sum(order_items.quantity * products.price),
// Running total across years (rows)
running_by_year: window_sum(revenue, order: 'rows'),
// Running total across categories (columns)
running_by_category: window_sum(revenue, order: 'columns'),
// Partition by category, order by year
yearly_growth_by_cat: window_sum(revenue, order: 'x_axis', partition: 'legend')
}
}
```
#### Overriding Sort Direction
You can override the visualization's sort order using `asc()` or `desc()`:
```aml
// Force ascending order even if visualization sorts descending
window_sum(revenue, order: 'rows' | asc())
// Force descending order
rank(order: 'columns' | desc())
```
#### Behavior Across Chart Types
Axis references work across all visualization types:
- **Pivot tables**: Both row and column axes are available
- **Bar/Line charts**: Typically only x-axis is available; column/legend axes may be ignored
- **Bar/Line charts with legend**: Both axes are available.
- If an axis is not applicable to the current chart type, it will be gracefully ignored
### Table
Window functions operate on a table, so they can only be used within the context of a table. Specifically, they can be utilized in the following contexts (and similar contexts):
**A Select Over a Table:**
```js
orders | select(orders.created_at, previous(orders.created_at, order: orders.created_at))
```
**A Filter Over a Table:**
```js
orders | filter(rank(order: orders.created_at) < 10)
```
**Inside a Dimension Definition of a Model:**
In this case, the table it operates on is the model's table.
```js
Model orders {
dimension previous_created_at {
type: string
sql: previous(orders.created_at, order: orders.created_at)
}
}
```
**Inside an Exploration:**
Here, the table will be the result of the exploration before the window function is applied.
```js
explore {
dimensions {
orders.created_at | month()
}
measures {
count(orders.id),
previous: previous(count(orders.id), order: orders.created_at | month())
}
}
```
In Holistics, this is equivalent to this UI.
## Default Behavior
### Default Partitioning
If **no partition** is specified and **no ordering** is specified, the entire table is treated as a single partition. This is equivalent to specifying a partition that groups all rows together.
If **no partition** is specified, but **an ordering** is specified, all other grouping dimensions in the exploration are used as the partitioning columns. For example, if you have a table with columns `month`, `status`, and `total_orders`, and you specify `order: month`, the table will automatically be partitioned by `status`. We choose this behavior to avoid the partial ordering issue.
If **any partition** is specified, the table is partitioned by the specified column(s). You should ensure that the combination of partitioning and ordering columns is unique to avoid partial ordering issues.
### Default Ordering
If **no ordering** is specified, the rows are not sorted within each partition. Note that this can lead to non-deterministic results if you use window functions that depend on ordering, as it introduces the partial ordering issue.
### Default Frame
If **no ordering** is specified, the default frame is `..`, which means all rows in the partition are considered for the window calculation.
If **an ordering** is specified, the default frame is `..0`, which means all rows from the first row in the partition to the current row are considered for the window calculation.
## Common Pitfalls
### Partial Ordering
Partial ordering occurs when the rows within a partition are not uniquely ordered. This situation arises when the ordering column(s) do not uniquely identify each row within a partition. As a result, the outputs of window functions can be non-deterministic. For example, if you order by `created_at` but have multiple rows with the same `created_at` value, the results may vary each time you run the query.
We recommend ensuring that the combination of partitioning and ordering columns is unique to avoid partial ordering issues.
### Ordering and Partitioning use dimensions not available in the LoD context
If you use dimensions that are not available in the Level of Detail (LoD) context for ordering or partitioning, the window function might not work as expected. AQL handles this issue in the following ways:
- **Ranking Functions:**
If the window function belongs to the Ranking Function category, the function will return `null` for any row that lacks the dimension in question. For example, `rank(order: orders.total_orders, partition: countries.name)` will return `null` for any row that does not have `countries.name` in its context.
This behavior can be observed in the Pivot's Grand Total Row. Since the Grand Total Row lacks any dimensions in the LoD context, all ranking functions will return `null`.
- **Aggregate Functions:**
If the window function belongs to the Aggregate Function category, the function will first add the missing dimensions to the LoD context.
For example, `window_avg(count(orders.id), 0..0, order: orders.created_at | quarter())` will first add `orders.created_at | quarter()` to the LoD context before calculating the average, in the case where the context only contains `orders.created_at | year()`. This behavior ensures that the window function works as expected in Subtotals and Grand Totals.
Here you can observed that the Sub Total for `2021` is calculated as the average of the quarters in `2021`, even though the LoD context for subtotals only contains `orders.created_at | year()`. Even the Grand Total is calculated as the average of all quarters, even though the LoD context for the Grand Total is empty.
It can be tricky to understand these metrics when used outside of the pivot table context and you cannot see the individual rows. So we recommend storing them in ad-hoc calculations rather than as reusable metrics.
---
## window_avg
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the average of rows in a range relative to the current row.
**Syntax**
```aml
window_avg(agg_expr)
window_avg(agg_expr, order: order_expr, ...)
window_avg(agg_expr, range, order: order_expr, ...)
window_avg(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_avg(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_avg(count(users.id))
window_avg(count(users.id), order: count(users.id) | desc())
window_avg(count(users.id), -2..2, order: users.created_at | month())
window_avg(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_avg(revenue, order: 'rows') // Running average across rows
window_avg(revenue, order: 'columns', partition: 'rows') // Running average within each row
window_avg(revenue, order: 'x_axis' | desc()) // Running average in reverse row order
window_avg(revenue, order: 'legend', partition: 'x_axis') // Running average within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression to be averaged.
- `range` (**optional**): A range of rows to include in the average. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The average of the current row and the rows within the specified range.
## Sample Usages
The most common use case for `window_avg` is to calculate the average of a column. In this example, we calculate the average of the count of users.
---
We can also use it to calculate the moving average of a column. In this example, we calculate the moving average of the count of users, ordered by the year they were created. Notice that we are using the `range` parameter to specify the range of rows to include in the average. In this case, we are calculating the average of the current row and the two rows before and after it (i.e. a total of 5 rows) with the range `-2..2`.
### Axis-Aware Usage
You can use axis references to create running averages that adapt to your visualization structure:
```aml
explore {
dimensions {
rows {
_quarter: orders.created_at | quarter()
}
columns {
_status: orders.status
}
}
measures {
order_count: count(orders.id),
// Running average across quarters for each status
running_by_quarter: window_avg(order_count, order: 'rows'),
// Running average across statuses for each quarter
running_by_status: window_avg(order_count, order: 'columns'),
// Override visualization sort order
running_reverse: window_avg(order_count, order: 'x_axis' | desc())
}
}
```
This approach is particularly useful when:
- Your visualization structure might change dynamically
- You want calculations to automatically adapt to different groupings
- You need to respect the visualization's sort order
## See also
- [`window_sum()`](/reference/aql/window_sum)
- [`window_count()`](/reference/aql/window_count)
- [Aggregator Functions](/reference/aql/aggregator-functions)
- [Window Functions](/reference/aql/window-function)
---
## window_count
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the count of rows in a range relative to the current row.
**Syntax**
```aml
window_count(agg_expr)
window_count(agg_expr, order: order_expr, ...)
window_count(agg_expr, range, order: order_expr, ...)
window_count(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_count(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_count(count(users.id))
window_count(count(users.id), order: count(users.id) | desc())
window_count(count(users.id), -2..2, order: users.created_at | month())
window_count(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_count(revenue, order: 'rows') // Running count across rows
window_count(revenue, order: 'columns', partition: 'rows') // Running count within each row
window_count(revenue, order: 'x_axis' | desc()) // Running count in reverse row order
window_count(revenue, order: 'legend', partition: 'x_axis') // Running count within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to count.
- `range` (**optional**): A range of rows to include in the count. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The count of the current row and the rows within the specified range.
## Sample Usages
Refer to the sample usages in [window_sum](/reference/aql/window_sum#sample-usages).
### Axis-Aware Usage
You can use axis references to create running counts that adapt to your visualization structure:
```aml
explore {
dimensions {
rows {
_quarter: orders.created_at | quarter()
}
columns {
_status: orders.status
}
}
measures {
order_count: count(orders.id),
// Running count across quarters for each status
running_by_quarter: window_count(order_count, order: 'rows'),
// Running count across statuses for each quarter
running_by_status: window_count(order_count, order: 'columns'),
// Override visualization sort order
running_reverse: window_count(order_count, order: 'x_axis' | desc())
}
}
```
This approach is particularly useful when:
- Your visualization structure might change dynamically
- You want calculations to automatically adapt to different groupings
- You need to respect the visualization's sort order
## See also
- [`window_sum()`](/reference/aql/window_sum)
- [`window_avg()`](/reference/aql/window_avg)
- [Aggregator Functions](/reference/aql/aggregator-functions)
- [Window Functions](/reference/aql/window-function)
---
## window_max
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the max of rows in a range relative to the current row.
**Syntax**
```aml
window_max(agg_expr)
window_max(agg_expr, order: order_expr, ...)
window_max(agg_expr, range, order: order_expr, ...)
window_max(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_max(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_max(count(users.id))
window_max(count(users.id), order: count(users.id) | desc())
window_max(count(users.id), -2..2, order: users.created_at | month())
window_max(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_max(revenue, order: 'rows') // Running max across rows
window_max(revenue, order: 'columns', partition: 'rows') // Running max within each row
window_max(revenue, order: 'x_axis' | desc()) // Running max in reverse row order
window_max(revenue, order: 'legend', partition: 'x_axis') // Running max within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to find max of.
- `range` (**optional**): A range of rows to include in the max. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The max of the current row and the rows within the specified range.
## Sample Usages
Refer to the sample usages in [window_sum](/reference/aql/window_sum#sample-usages).
## See also
- [`window_min()`](/reference/aql/window_min)
- [`window_avg()`](/reference/aql/window_avg)
- [Aggregator Functions](/reference/aql/aggregator-functions)
- [Window Functions](/reference/aql/window-function)
---
## window_min
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the min of rows in a range relative to the current row.
**Syntax**
```aml
window_min(agg_expr)
window_min(agg_expr, order: order_expr, ...)
window_min(agg_expr, range, order: order_expr, ...)
window_min(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_min(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_min(count(users.id))
window_min(count(users.id), order: count(users.id) | desc())
window_min(count(users.id), -2..2, order: users.created_at | month())
window_min(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_min(revenue, order: 'rows') // Running min across rows
window_min(revenue, order: 'columns', partition: 'rows') // Running min within each row
window_min(revenue, order: 'x_axis' | desc()) // Running min in reverse row order
window_min(revenue, order: 'legend', partition: 'x_axis') // Running min within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to find min of.
- `range` (**optional**): A range of rows to include in the min. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The min of the current row and the rows within the specified range.
## Sample Usages
Refer to the sample usages in [window_sum](/reference/aql/window_sum#sample-usages).
### Axis-Aware Usage
You can use axis references to find running minimums that adapt to your visualization structure:
```aml
explore {
dimensions {
rows {
_quarter: orders.created_at | quarter()
}
columns {
_status: orders.status
}
}
measures {
order_count: count(orders.id),
// Running min across quarters for each status
running_by_quarter: window_min(order_count, order: 'rows'),
// Running min across statuses for each quarter
running_by_status: window_min(order_count, order: 'columns'),
// Override visualization sort order
running_reverse: window_min(order_count, order: 'x_axis' | desc())
}
}
```
This approach is particularly useful when:
- Your visualization structure might change dynamically
- You want calculations to automatically adapt to different groupings
- You need to respect the visualization's sort order
## See also
- [`window_max()`](/reference/aql/window_max)
- [`window_avg()`](/reference/aql/window_avg)
- [Aggregator Functions](/reference/aql/aggregator-functions)
- [Window Functions](/reference/aql/window-function)
---
## window_stdev
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the (sample) standard deviation of rows in a range relative to the current row.
**Syntax**
```aml
window_stdev(agg_expr)
window_stdev(agg_expr, order: order_expr, ...)
window_stdev(agg_expr, range, order: order_expr, ...)
window_stdev(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_stdev(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_stdev(count(users.id))
window_stdev(count(users.id), order: count(users.id) | desc())
window_stdev(count(users.id), -2..2, order: users.created_at | month())
window_stdev(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_stdev(revenue, order: 'rows') // Running standard deviation across rows
window_stdev(revenue, order: 'columns', partition: 'rows') // Running standard deviation within each row
window_stdev(revenue, order: 'x_axis' | desc()) // Running standard deviation in reverse row order
window_stdev(revenue, order: 'legend', partition: 'x_axis') // Running standard deviation within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to find the (sample) standard deviation of.
- `range` (**optional**): A range of rows to include in the (sample) standard deviation. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`.
You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by row position in the visualization
- `'columns'` or `'legend'`: Order by column position in the visualization
Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The (sample) standard deviation of the current row and the rows within the specified range.
## Sample Usages {#sample-usages}
The most common use case for `window_stdev` is to draw a Process Behavior Chart (PBC) for a process. The PBC is a type of control chart that shows the process variation over time. The `window_stdev` function can be used to calculate the standard deviation of a process variable over a range of time periods. This can help you identify when the process is out of control and needs to be adjusted.
Here is an example of how you can use `window_stdev` to create an PBC in Holistics:
## See also
- [`window_stdevp()`](/reference/aql/window_stdevp)
- [`window_var()`](/reference/aql/window_var)
- [Window Functions](/reference/aql/window-function)
---
## window_stdevp
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the (population) standard deviation of rows in a range relative to the current row.
**Syntax**
```aml
window_stdevp(agg_expr)
window_stdevp(agg_expr, order: order_expr, ...)
window_stdevp(agg_expr, range, order: order_expr, ...)
window_stdevp(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_stdevp(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_stdevp(count(users.id))
window_stdevp(count(users.id), order: count(users.id) | desc())
window_stdevp(count(users.id), -2..2, order: users.created_at | month())
window_stdevp(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_stdevp(revenue, order: 'rows') // Running standard deviation across rows
window_stdevp(revenue, order: 'columns', partition: 'rows') // Running standard deviation within each row
window_stdevp(revenue, order: 'x_axis' | desc()) // Running standard deviation in reverse row order
window_stdevp(revenue, order: 'legend', partition: 'x_axis') // Running standard deviation within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to find the (population) standard deviation of.
- `range` (**optional**): A range of rows to include in the (population) standard deviation. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`.
You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by row position in the visualization
- `'columns'` or `'legend'`: Order by column position in the visualization
Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The (population) standard deviation of the current row and the rows within the specified range.
## Sample Usages
Please refer to the sample usages in [window_stdev](/reference/aql/window_stdev#sample-usages).
## See also
- [`window_stdev()`](/reference/aql/window_stdev)
- [`window_varp()`](/reference/aql/window_varp)
- [Window Functions](/reference/aql/window-function)
---
## window_sum
## Definition
A window aggregation function that returns the sum of rows in a range relative to the current row.
**Syntax**
```aml
window_sum(agg_expr)
window_sum(agg_expr, order: order_expr, ...)
window_sum(agg_expr, range, order: order_expr, ...)
window_sum(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_sum(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_sum(count(users.id))
window_sum(count(users.id), order: count(users.id) | desc())
window_sum(count(users.id), -2..2, order: users.created_at | month())
window_sum(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_sum(revenue, order: 'rows') // Running sum across rows
window_sum(revenue, order: 'columns', partition: 'rows') // Running sum within each row
window_sum(revenue, order: 'x_axis' | desc()) // Running sum in reverse row order
window_sum(revenue, order: 'legend', partition: 'x_axis') // Running sum within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression to be summed.
- `range` (**optional**): A range of rows to include in the sum. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The sum of the current row and the rows within the specified range.
## Sample Usages {#sample-usages}
Notice that it automatically resets the sum when the gender changes. This is because we only specify `order` and thus `reset` defaults to `gender`. If you want it to not reset, you can order by both `gender` and `status`.
By default, the range is `..0` (from the first row to the current row). Thus if we want to sum all rows, we can use `..` for the range. And since we are not using relative range, we can omit the `order:` parameter.
### Axis-Aware Usage
You can use axis references to create running totals that adapt to your visualization structure:
```aml
explore {
dimensions {
rows {
_quarter: orders.created_at | quarter()
}
columns {
_status: orders.status
}
}
measures {
order_count: count(orders.id),
// Running total across quarters for each status
running_by_quarter: window_sum(order_count, order: 'rows'),
// Running total across statuses for each quarter
running_by_status: window_sum(order_count, order: 'columns'),
// Override visualization sort order
running_reverse: window_sum(order_count, order: 'x_axis' | desc())
}
}
```
This approach is particularly useful when:
- Your visualization structure might change dynamically
- You want calculations to automatically adapt to different groupings
- You need to respect the visualization's sort order
## See also
- [`window_avg()`](/reference/aql/window_avg)
- [`window_count()`](/reference/aql/window_count)
- [Aggregator Functions](/reference/aql/aggregator-functions): non-windowed equivalents
- [Window Functions](/reference/aql/window-function)
---
## window_var
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the (sample) variance of rows in a range relative to the current row.
**Syntax**
```aml
window_var(agg_expr)
window_var(agg_expr, order: order_expr, ...)
window_var(agg_expr, range, order: order_expr, ...)
window_var(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_var(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_var(count(users.id))
window_var(count(users.id), order: count(users.id) | desc())
window_var(count(users.id), -2..2, order: users.created_at | month())
window_var(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_var(revenue, order: 'rows') // Running variance across rows
window_var(revenue, order: 'columns', partition: 'rows') // Running variance within each row
window_var(revenue, order: 'x_axis' | desc()) // Running variance in reverse row order
window_var(revenue, order: 'legend', partition: 'x_axis') // Running variance within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to find the (sample) variance of.
- `range` (**optional**): A range of rows to include in the (sample) variance. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`.
You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by row position in the visualization
- `'columns'` or `'legend'`: Order by column position in the visualization
Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The (sample) variance of the current row and the rows within the specified range.
## Sample Usages
Please refer to the sample usages in [window_stdev](/reference/aql/window_stdev#sample-usages).
## See also
- [`window_varp()`](/reference/aql/window_varp)
- [`window_stdev()`](/reference/aql/window_stdev)
- [Window Functions](/reference/aql/window-function)
---
## window_varp
:::tip Knowledge Checkpoint
Readings that will help you understand this documentation better:
- [Window Functions Overview](/reference/aql/window-function)
:::
## Definition
A window aggregation function that returns the (population) variance of rows in a range relative to the current row.
**Syntax**
```aml
window_varp(agg_expr)
window_varp(agg_expr, order: order_expr, ...)
window_varp(agg_expr, range, order: order_expr, ...)
window_varp(agg_expr, range, order: order_expr, ..., reset: partition_expr, ...)
window_varp(agg_expr, range, order: order_expr, ..., partition: partition_expr, ...)
```
```aml title="Examples"
window_varp(count(users.id))
window_varp(count(users.id), order: count(users.id) | desc())
window_varp(count(users.id), -2..2, order: users.created_at | month())
window_varp(count(users.id), order: users.created_at | month(), reset: users.gender)
// Axis-aware examples
window_varp(revenue, order: 'rows') // Running variance across rows
window_varp(revenue, order: 'columns', partition: 'rows') // Running variance within each row
window_varp(revenue, order: 'x_axis' | desc()) // Running variance in reverse row order
window_varp(revenue, order: 'legend', partition: 'x_axis') // Running variance within each column
```
**Input**
- `agg_expr` (**required**): An aggregation expression that we want to find the (population) variance of.
- `range` (**optional**): A range of rows to include in the (population) variance. Negative values indicate rows before the current row, and positive values indicate rows after the current row, while 0 indicates the current row. If the beginning or end of the range is not specified, the range will include all rows from the beginning or end of the table. By default, if the range is not specified:
- If `order` is specified, the range is `..0` (from the first row to the current row).
- If `order` is not specified, the range is `..` (from the first row to the last row).
- `order` (**required**, **repeatable**): A field that is used for ordering. The order defaults to ascending. The order can be set explicitly with `asc()` or `desc()`. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions):
- `'rows'` or `'x_axis'`: Order by dimensions mapped to rows/x-axis
- `'columns'` or `'legend'`: Order by dimensions mapped to columns/legend
- Axis references can be modified with `asc()` or `desc()`: `order: 'rows' | desc()`
:::warning
If the specified order does not uniquely identify rows, the result of the function can be non-deterministic. For example, if you use `order: users.age`, and there are multiple users with the same age in the same partition, the result can be unexpected.
:::
- `partition` or `reset` (**repeatable**, **optional**): A field that is used for partitioning the table. You can also use [axis references](/reference/aql/window-function#axis-aware-window-functions) like `'rows'`, `'columns'`, `'x_axis'`, or `'legend'`. If partitions are not specified:
- If `order` is specified, the table will be partitioned by all other grouping columns.
- If `order` is not specified, the table will be considered as a single partition.
**Output**
The (population) variance of the current row and the rows within the specified range.
## Sample Usages
Please refer to the sample usages in [window_stdev](/reference/aql/window_stdev#sample-usages).
## See also
- [`window_var()`](/reference/aql/window_var)
- [`window_stdevp()`](/reference/aql/window_stdevp)
- [Window Functions](/reference/aql/window-function)
---
## with_relationships
### Definition
Specifies existing relationships to activate during calculating the measure
This function comes into handy when you have 2 tables that are connected through multiple paths, where only one path can be activated to avoid [ambiguous path](/docs/joins/path-ambiguity).
**Syntax**
```aml
with_relationships(measure, relationship, ...)
```
```aml title="Examples"
// Activate the relationship between order_items and products
sum(order_items.revenue) | with_relationships(order_items.product_id > products.id)
```
**Input**
- `measure`: A measure that you want to override the relationships
- `relationship` (**repeatable**): A relationship that you want to activate. It comes in 2 forms:
- `model1.column1 > model2.column2` (many-to-one). E.g. `order_items.product_id > products.id`
- `model1.column1 - model2.column2` (one-to-one). E.g. `merchants.admin_id - users.id`
:::caution
Only relationships that are already defined in the dataset can be used in `with_relationships`
:::
**Output**
Measure with specified relationships.
:::caution
Relationships activated by `with_relationships` will have priority over normal relationships, **but the relationship path in the highest tier will still always be used**. See path [prioritization algorithm](#prioritization-algorithm).
:::
---
### Path Prioritization Algorithm {#prioritization-algorithm}
When multiple paths exist from the **filter/dimension model** to the **target metric model**, AQL choose the most optimal path using this algorithm.
#### Rank the path using the following criteria:
a. **Tier Precedence** *(Lower tier is better)*
- **Tier 1** paths always rank higher than **Tier 2+** paths.
- If multiple paths are in the same tier, proceed to **Weight Comparison**.
b. **Weight Comparison** *(Higher weight is better)*
- Compare the weights of the paths.
- If one path has a relationship with a **higher weight**, it ranks higher.
- If weights are **equal**, proceed to **Path Length**.
c. **Path Length** *(Shorter is better)*
- Among paths with equal tier and weight, choose the one with the **shortest length**.
- If lengths are **equal**, paths are considered **equivalent**.
#### Final Decision
- If there is **one clear winner**, **use it**.
- If paths are **equivalent** with no clear winner:
- **Throw an error**
- Ask users to **add `with_relationships()`** to clarify intent
#### Tier Explanation
| **Tier** | **Description** |
|----------|---------------------------------------------------------------------------------------------------------------------------|
| Tier 1 | Path contains **only one-to-many** relationships (filters flow from dim to fact model) |
| Tier 2 | Path contains **only many-to-one** relationships (filters flow from fact to dim model) |
| Tier 3 | Path follows the [many-to-many](/docs/relationships#handling-many-to-many-n-n-relationship) with a junction table pattern |
| Tier 4 | Path contains a **mixed pattern** not conforming to the above |
## Relationship Weight
- Each relationship in a path has a **default weight of 0**.
- `with_relationships()` given weight to relationships, nested one will be given more weight.
```aml
with_relationships(
metric_1,
with_relationships(
metric_2,
order_items.product_id > products.id // this will have higher weight than the one below
)
merchants.admin_id > users.id
)
```
### Sample Usages
Consider the scenario where we have two facts models `order_items` and `shippings`, both connected to the dimension models `products`, `orders`, and `countries`.
In order to avoid ambiguous paths, for `order_items` relationships, we can only activate one link between `order_items` and `products`, and deactivate the other links. While deactivating these relationships, we still need to keep those definitions in order to reuse it in `with_relationships`.
```aml
Dataset e_commerce {
(...)
relationships: [
// shippings relationships
relationship(shippings.product_id > products.id, true),
relationship(shippings.order_id > orders.id, true),
relationship(shippings.country_id > countries.id, true),
// order_items relationships
relationship(order_items.product_id > products.id, true),
relationship(order_items.order_id > orders.id, false), // deactivated
relationship(order_items.country_id > countries.id, false) // deactivated
]
}
```
Then override the deactivated relationships in the measure/metric definition using `with_relationships`. We can also omit the link from `order-items`-`products` since we already activate it in the dataset.
```aml
Model order_items {
(...)
measure total_sales {
label: 'Total Sales'
type: 'number'
definition: @aql
sum(order_items.revenue) |
with_relationships(
// You can omit the first relationship as it is already activated in the dataset
// order_items.product_id > products.id,
order_items.order_id > orders.id,
order_items.country_id > countries.id,
)
;;
}
}
```
## See also
- [Relationships](/reference/aml/relationship): concept
- [Common Relationship Problems](/as-code/reference/common-relationships-problems)
- [Metric Context](/as-code/aql/learn/metric-context)
---
## Reference overview
This section is pure lookup material: syntax definitions, type signatures, function specs, and error codes for AML and AQL. For conceptual guides and how-tos, see [Analytics as Code](/as-code/aml/).
## AML Reference
[AML (Analytics Modeling Language)](/reference/aml/) is the declarative language for defining your semantic layer: models, fields, datasets, relationships, dashboards, and more.
How the AML reference is organized and where to start.
Side-by-side examples for the most common AML objects.
Table models and query models, and the properties they support.
Dimensions and measures, with their definitions and options.
Collections of models tied together with relationships.
Dashboard-as-code definitions and their blocks.
The AML type system and how values are typed.
Constants, functions, modules, extend, and partial.
## AQL Reference
[AQL (Analytics Query Language)](/reference/aql/) is the expression language for querying AML models, used in metrics, filters, calculated fields, and dataset field definitions.
How the AQL reference is organized and where to start.
Table, metric, and explore expressions, and how they evaluate.
Comparison, arithmetic, logical, and pipe operators.
Scalars, tables, dimensions, fields, and measures.
Table, LOD, time-based, window, and inline functions.
Every AQL function in one scannable table.
Error codes and how to fix them.
---
## Access Demo Account
:::info Demo Only
This account is for testing Holistics 4.0 features only, and it will be deleted **every Monday at 5 PM (UTC)**. Please make sure you do not keep any important data in this account.
:::
To access our sandbox account of a shared public instance, please click visit: **https://demo4.holistics.io/demo**

The demo account is already connected to a demo database. If you want to connect to your own database, please consider using our [Free Trial](https://www.holistics.io/request-trial/).
---
## Add Custom Dimensions and Measures
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Data Model concept](/docs/data-model)
- [Data Model: Dimension and Measure](/docs/model-fields)
:::
This is where things start to get interesting. in Holistics, you can create [Custom Dimensions and Measures](/docs/model-fields) to assist your data exploration.
**Custom Dimensions** are created from **non-aggregate** functions that transform existing model fields, and **Custom Measures** are **aggregate** functions like `SUM`, `AVG`, `MIN`, `MAX`... that summarize your data across some dimensions.
Basic aggregations are provided in Holistics's Data Exploration UI, but with **Custom Measures** you can specify more complicated calculations like `conditional SUM`.
The video below demonstrates the steps to add Custom Dimensions to your model:
The code we use for the custom dimension is as follows:
```sql
case
when {{budget}} < 1000000 then 'Under $1mil'
when {{budget}} >= 1000000 and {{budget}} < 5000000 then '$1mil - $5mil'
when {{budget}} >= 5000000 and {{budget}} < 10000000 then '$5mil - $10mil'
when {{budget}} >= 10000000 and {{budget}} < 20000000 then '$10mil - $20mil'
when {{budget}} >= 20000000 and {{budget}} < 50000000 then '$20mil - $50mil'
when {{budget}} >= 50000000 and {{budget}} < 100000000 then '$50mil - $100mil'
when {{budget}} >= 100000000 and {{budget}} < 200000000 then '$100mil - $200mil'
when {{budget}} >= 200000000 and {{budget}} < 300000000 then '$200mil - $300mil'
when {{budget}} >= 300000000 then 'Over $300mil'
else 'N/A' end
```
---
## Add Filters to Dashboard
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard](/docs/dashboards)
- [Dashboard component: Filters](/docs/filters/index.md)
:::
A static dashboard may be good for a quick overview of the matter, but **it is not useful enough if your viewers cannot interact with it to explore the data themselves**. Let's add some [filters](docs/filters/index.md) to help them do that!
In Holistics, there are two filter categories: **[Field Filters](/docs/filters/field-filters.md)**, and **Manual Filters** **([Text](/docs/filters/text-filters.md), [Number](/docs/filters/number-filters.md), [Date](/docs/filters/date-filters), [True/False](/docs/filters/truefalse-filters.md)).**
- **[Field Filter](/docs/filters/field-filters.md)** works in a streamlined way: it gets filter values from a field in a Dataset, on the data type of that field, and automatically map to widgets using that Dataset.
- On the other hand, with **Manual Filter**, we specify the filter data type manually, and have to manually map it to the desired widgets.
:::info Field Filters or Manual Filters?
Field Filter works best when your dashboard has multiple widgets created from the same Dataset, while Manual Filter suits the situation when you have widgets coming from different Datasets.
:::
In the following example, we will add a `Release Date filter` to our dashboard:
---
## Add Relationships
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Data Model concept](/docs/data-model)
- [Data Model: Dimension and Measure](/docs/model-fields)
- [Data Model Relationship](/docs/relationships)
:::
In [Create and explore a Dataset](create-explore-dataset.md), we have used a single model to answer a simple question. What if we want to know, say, *the total revenue of movies released by companies in the year 2017?*
The name of production companies are not in the `movies_metadata` model, but the `movies_production_companies` model. We will need to [add a relationship](/docs/relationships) to link these two together. The video below demonstrates the steps to add a new relationship to your dataset, assuming that the `movies_production_companies` model has is already in your dataset:
Congratulations 🥳! You are off to a good start. However,*this is just a glimpse of what Holistics can do*. In the next section we will explore more functionalities that enable you to answer complex analytics questions.
💡 If you want to practice this section more, try setting up other models' relationships based on this diagram: [https://dbdiagram.io/embed/6077d1d3b6aeb3052d90320c](https://dbdiagram.io/embed/6077d1d3b6aeb3052d90320c)
---
## Add Report Widget to Dashboard
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard](/docs/dashboards)
:::
Our `Movies Statistics Overview` dashboard now has one report widget: `Revenue by Production Company`. Let's try adding another report widget to it. This widget will answer the business question: *"How many movies are released each year?"* with a [Column Chart](/docs/charts/column-chart).
In [Create a dashboard](create-a-dashboard.md) tutorial, we have added our widget directly in the dataset exploration workflow. You can also do this from the dashboard page.
Let's quickly go through the steps!
1. Click **Add → Add New Report**.
2. You will be navigated to the Dataset selection screen. Select the Movie Analysis dataset that we already created.
You should see the familiar dataset exploration UI:
3. Select **Column Chart.**
- At the **X Axis** field, select **Release Date** and choose Year transformation.
- At the Y Axis field, select the **All Movies Count** measure that we created previously
- Click **Get Result** to view the resulted visualization
4. Click **Save** to open the Save Report modal.
Enter the title for your report, and click **Confirm** to finish.
5. Once saved, you will be redirected to the dashboard. You should see two report widgets on the dashboard now. Try adding more!
---
## Create a dashboard
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard](/docs/dashboards)
:::
In the [Add Relationships](add-relationships.md) tutorial, we were exploring the Movies Analysis dataset to answer common business questions. However, the results you found at this step are only available to you. In order to make it available to others, you need to create a Dashboard to easily share your insights with others.
The video below will walk you through the basic steps to create a dashboard:
---
## Create and explore a Dataset
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dataset concept](/docs/datasets)
:::
To explore data from Data Models, we will need to put it in a [Dataset](/docs/datasets). A **Dataset** is a container of multiple related Data Models which enable you to explore the data and create visualizations.
Dataset can contain multiple Models, but at this point it will only include the `movies_metadata` model we have just created.
The video below demonstrates the steps to create and explore a dataset;
---
## Create Table Models
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Data Model concept](/docs/data-model)
- [Table Model](/docs/table-models)
- [Import Model](/docs/import-models)
- [Query Model](/docs/query-models.md)
:::
In the Holistics's demo account, you will have access to the `demo_database` Data Source. This Data Source is a PostgreSQL database that contains several demo datasets in different schemas, and the data we will use for this tutorial is in the `movies` schema.
In Holistics, the first step to access the tables in your database is to create [Table Models](/docs/table-models). Table Models are like views created on top that allow you to add more functionality to your physical tables without actually modifying them.
After creating table models, to help your end-users understand the context of data, it is good practice to add descriptions to important fields, or change the default field label to make it clear. You can do so easily in the **List** view of the model.
---
## Walkthrough Tutorial
In this series of tutorials, you will go through a full Holistics workflow, from data modeling to dashboard development. We will explore a data set from [TMDB](https://www.themoviedb.org/) - a movie database website and answer questions like:
- How many movies have been released over the years?
- What are the most successful movie production companies?
- What are the most successful movies in terms of revenue?
- What are the most popular genres
... and more!
## Overview of Holistics workflow
In this tutorial, we will walk you through a basic Holistics workflow:
- [Getting a demo account](access-demo-account.md)
- Develop a Dataset:
- [Create Table Models](create-table-models.md)
- [Add custom fields](add-custom-fields.md)
- [Create & Explore a Dataset](create-explore-dataset.md)
- [Add Relationships](add-relationships.md)
- Develop a Dashboard:
- [Create a dashboard](create-a-dashboard.md)
- [Add Widgets to the Dashboard](add-widgets.md)
- [Add Filters to the Dashboard](add-filters.md)
- [Interacting with the Dashboard](interact-dashboard.md)
- [Set up a Drill-through](use-drill-through.md)
- [Schedule a Dashboard](schedule-dashboard.md)
- [Transform your data](transform-data.md)
To begin, head over to [Access Demo Account](access-demo-account.md) to get yourself a demo environment inside Holistics.
---
## Interact with the Dashboard
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard](/docs/dashboards)
:::
In this tutorial, let's go through a few basic interactions with a dashboard.
## Editing a widget
In this section, we will add more information to the basic **Revenue by Production Company** widget.
1. Click on the **...** symbol on top of a widget → **Edit Report**
The widget edit screen will appear.
2. Drag in the **Imdb ID** field, and change the aggregation to **CountD** to count number of movies produced by a company.
Click Get Result if you want to view the changes.
3. Click **Save** to finish, and you will be navigated back to the dashboard
## Explore a widget
When viewing a dashboard, you can also open an Exploration screen of a widget to freely drag-and-drop without accidentally modifying the underlying widget.
1. Right click on the visualization → Explore
Another way is click on the **...** symbol → **Explore Data.**
The Exploration modal will appear with the current dashboard filter applied:
This widget is displaying a scatter chart of movie budget - revenue grouped by Budget Range. Now we only want to look at aggregation metrics of each budget range.
2. Click on **Data Table** visualization. The **Table Fields** area will revert to the basic field listing format.
3. Now we change things up a bit
- Remove **Original Title** field.
- Drag the **Budget Range** to the top of the list
- Drag in the Imdb ID field, change aggregation method to **Count Distinct.** Rename the field to Movies Count
- Change **Budget** and **Revenue**'s aggregations to **Sum**
4. Click **Get Result**. We will see there is a row where Budget Range is null - this is because there are movies with no **Budget** value.
5. If we want to remove that row, in the **Conditions** section, select **Budget Range** field**, "is not null"** operator → Click **Apply**
A new condition will be added to the setting.
6. Click **Get Result,** and you will see the NULL Budget Range row was removed
7. Remove **Release Date last 20 years** condition since we will not need it. **Get Result**, then click on the **v** icon on top of **Movies Count** → **Sort Descending.**
We will see that in this data set, the largest portion of the movie has a budget between $1 million - $5 million USD.
8. Click **Save As →** Input widget title and select a new dashboard to save this exploration into. In this case, we have created a **Movie Production Companies Overview** dashboard beforehand.
9. Follow the link provided and you will be navigated to the new Dashboard
## Business Calculation
*Among the movies with known budgets, how many movies are profitable?* We will answer this using a **Business Calculation.**
**Business Calculation** feature allows end-users add calculations to the report using a simple set of syntax. In this section, we will create a **Profitable Movies Count** calculation that only count the movies with revenue greater than budget.
1. Click **...** on top of the **Movie Metrics by Budget Range** widget → **Edit Report** to enter the report edit mode.
2. In the Table Fields area, click **Add field...** → **Add Business Calculation.**
The **Create Business Calculation** modal will appear. Enter **Field Label**, **Description** (if necessary), and the **Formula:**
```sql
count(
case(
when: movies_metadata.revenue > movies_metadata.budget
, then: movies_metadata.id
, else: null
)
)
```
3. Click **Create** to finish. **Get Result** to view the result of the new calculation
4. Click **Save** to finish, and you will be brought back to the dashboard view.
---
## Schedule a Dashboard Export
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard](/docs/dashboards)
:::
In the previous tutorials, we built a `Movie Statistics Dashboard`. Let's say you and your friends are movie enthusiasts and would like to keep track of the development of the movie industry regularly. This can be easily done by [scheduling an export](/docs/delivery/email-schedules) to your emails.
:::info Did you know
Apart from [email export schedule](/docs/delivery/email-schedules), Holistics also offers a variety of dashboard schedule methods such as [Slack export schedule](/docs/delivery/slack-schedules), [Google Sheets export schedule](/docs/delivery/google-sheets), etc. Head over to the **Sharing Data Externally** section to learn more!
:::
Let's review the steps to schedule an email export on our dashboard!
1. At the Dashboard view, click on **Export → Send to Email**
2. The **Data Delivery** modal will appear. Here you can customize how you want to deliver the email
- **Recipients:** Valid email addresses that will receive the mail
- **Frequency:** Specify how often and when you will receive the mail
- **Attachments:** If you select any of these, you will receive a copy of the dashboard in the specified format along with the mail
- **Filters:** If you want to override the dashboard's default filters, you can set a new filter condition here.
3. Click **Send Test** if you want to send a single email to the specified addresses to test your setup.
4. Click **Save** to finish the process. The dashboard export will be sent when the next time point specified in Frequency is reached.
---
## Transform your data
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Data Model: Query Model](/docs/query-models.md)
- [Holistics Expression](/docs/expression/intro.md)
:::
Sometimes your raw data is not in a format suitable for exploration and reporting. In such cases, you can do some light data preparation using Holsitics's [Query Model](/docs/query-models.md).
For example, in our Movies dataset, the `credits` table contains information about all credited members of the cast and crew in a movie in JSON arrays:
To make this data ready for exploration, we will need to normalized the nested arrays into separate models, and then add them to the dataset.
1. On the Data Modeling page, navigate to the folder where you already put other movies models. Click on **Create Data Model -> Query Model**
2. The **Create Query Model** will appear. Here you will enter the SQL code to transform your data. For full syntax reference, please check [Syntax for Querying Models](/docs/modeling/query-syntax.md)
```js
select
{{#credits.movie_id}}
, (cast_members ->> 'id')::integer as id
, (cast_members ->> 'cast_id')::integer as cast_id
, cast_members ->> 'name' as name
, case (cast_members ->> 'gender')::integer
when 1 then 'female'
when 2 then 'male'
when 0 then 'unknown'
end as gender
, cast_members ->> 'character' as character
, cast_members ->> 'credit_id' as credit_id
, cast_members ->> 'profile_path' as profile_path
from {{#movies_credits credits}}
, jsonb_array_elements({{#credits.casts}}) as cast_members
```
Input `movies_cast_members` as the model name. Click **Run** to run the SQL and preview your data.
3. When you have validated that the data is in your desired form, click **Save** to finish.
The new model will appear in the folder tree with a blue dot:
4. Similarly, create a `movies_crew_members` using the following SQL:
```js
select
{{#credits.movie_id}}
, (crew_members ->> 'id')::integer as id
, crew_members ->> 'job' as job
, crew_members ->> 'name' as name
, case (crew_members ->> 'gender')::integer
when 1 then 'female'
when 2 then 'male'
when 0 then 'unknown'
end as gender
, crew_members ->> 'credit_id' as credit_id
, crew_members ->> 'department' as department
, crew_members ->> 'profile_path' as profile_path
from {{#movies_credits credits}}
, jsonb_array_elements({{#credits.crews}}) as crew_members
```
5. Navigate to the newly created models, and add relationships with the `movies_metadata` model
6. Go the the **Movies Analysis** dataset you have created in the previous steps, and add the two new models in:
Click **Next step**, and you will see the two new relationships in the list:
Click **Save** to finish.
7. Let's test the new set with some basic questions. For example, who is the actor/actress with the highest credited movies count?
Drag in the **Name**, **Field** and **Movie Id** field from the `movies_cast_members` model. Change aggregation of Movie Id to **Count (Distinct)**, then click **Get Result**.
Looks like Bess Flower is the one. According to the data, she has 240 movies credited, which is way higher than the count of other more well-known movie stars. A quick Google search reveals that she is actually considered "The Queen of the Hollywood Extras."
8. If you take a look at the generated query in the **Executed Query** tab, you will see that the full SQL we wrote in the `movies_cast_members` model was inserted here as a CTE:
In other words, every you explore this new model, the whole SQL will be executed. Our underlying `credits` table is relatively small so this is still OK, but in cases when the table is large, you will want to materialize (**persist**) the transformation result to a physical table to improve query performance.
Go back to the `movies_cast_members` model, you will see the **Storage settings** section. This is where you set up the persistence.
Click on the toggle, and the **Storage setings** modal will appear. Here you can specify details like, destination schema, destination table name, schedules, and storage mode. For the full details, please refer to the [Storage Settings](/docs/storage-settings.md) docs.
12. Click on **Save and Run**. This will run the SQL and write the result into a table in the database.
13. Go back to the dataset exploration screen, and click **Get Result** again. Check the Executed Query tab, and you will see that now the query is selecting from a table instead of a CTE:
And that's it! You have gone through the basic steps to work with Query Models in Holistics. With this new ability, you can answer even more complex analytics questions provided that you are well-versed in SQL.
---
## Set up Drill-throughs in your report
:::tip Knowledge Checkpoint
A grasp of these concepts will help you understand this documentation better:
- [Dashboard](/docs/dashboards)
- [Dashboard component: Filters](/docs/filters)
:::
In the previous tutorials, we have created the `Movies Statistics Overview` dashboard which gives an overview of the whole movie industry.
While looking at the big picture, it is natural for dashboard viewers to want to explore more on things that they find interesting. Let's set up a [drill-through](/docs/interactions/drill-through) so that they can learn about, say `Warner Bros`, who is the top perfomer in the industry while viewing our report.
Assuming that we already have a **Movie Production Companies Overview** dashboard that provides detailed company-level metrics:
With Holistics's **Drill-through** feature, you can use a data point in dashboard A to filter and view dashboard B without leaving the dashboard A's view.
Let's go over the steps to do that!
1. In the Company dashboard, create a field filter with the following path: **Movies Analysis → Production Companies → Company name**:
2. Enable **Drill-through,** then click **Submit** to finish.
The filter will have a small arrow icon that indicates this one has Drill-through enabled:
Now when viewing any widget that use the same **Movies Analysis → Production Companies → Company name** field, you can drill to this dashboard.
3. To test the setup, go back to the Movies Statistics Overview dashboard. Right click on the cell that contains `Warner Bros.` value, you will see a new option available in the menu.
4. Click on the **Movie Production Companies Overview** option. The dashboard will appear in a modal, and you can see the `Warner Bros.` value was used as a filter value:
---
## Using Aggregate Awareness
:::tip Important
Please refer to [Aggregate Awareness](/docs/aggregate-awareness) to see the full documentations.
:::
## Introduction
To improve query performance and reduce query costs when reporting from large datasets or tables, data teams typically create materialized/persisted views (physical tables) of different aggregations.
This tutorial will show an example on how to create and utilize such persisted aggregations using Holistics' **Aggregate Awareness**.
## Scenario
Let's say we have an `ecommerce` Dataset with these Relationships:
```aml
Dataset ecommerce {
__engine__: 'aql'
label: 'Ecommerce'
description: ''
data_source_name: 'hlite_demo'
models: [
demo_order_items,
demo_orders,
demo_products,
demo_merchants,
demo_countries,
demo_cities,
demo_categories,
demo_users
]
relationships: [
relationship(demo_order_items.product_id > demo_products.id, true)
,
relationship(demo_order_items.order_id > demo_orders.id, true)
,
relationship(demo_orders.user_id > demo_users.id, true)
,
relationship(demo_users.city_id > demo_cities.id, true)
,
relationship(demo_cities.country_code > demo_countries.code, true)
,
relationship(demo_products.merchant_id > demo_merchants.id, true)
,
relationship(demo_products.category_id > demo_categories.id, true)
]
owner: 'scott.bui@holistics.io'
}
```
## Use case: Sum of Item Quantity sold over Year
### Without Aggregate Awareness
To find out the **Sum of item quantity sold over Year**, we can make this exploration:
* Table Fields:
* `orders.created_at` (transform: `Year`)
* `order_items.quantity` (aggregation: `Sum`)
If we run the above Executed Query with `EXPLAIN ANALYZE`, we get this query execution analysis:
```sql
EXPLAIN ANALYZE
SELECT
TO_CHAR((CAST ( (DATE_TRUNC ( 'year', (CAST ( "demo_orders"."created_at" AS timestamptz )) AT TIME ZONE 'Europe/London' )) AT TIME ZONE 'Europe/London' AS timestamptz )) AT TIME ZONE 'Europe/London', 'YYYY-MM-DD HH24:MI:SS.US') AS "dy_do_ca_01c493",
SUM("demo_order_items"."quantity") AS "s_doi_q_df1043"
FROM
"demo"."order_items" "demo_order_items"
LEFT JOIN "demo"."orders" "demo_orders" ON "demo_order_items"."order_id" = "demo_orders"."id"
GROUP BY
1
```
Some highlights: The Database has to
* Aggregate on **54,783** rows
* Use **793kB** of memory
* Take **62.797ms** in total
### Using Aggregate Awareness
#### 1. Define the Pre-Aggregate
For our use case, we can define a Pre-Aggregate with:
* 1 Dimension:
* `demo_orders.created_at` (time_granularity: `year`)
* 1 Measure:
* `demo_order_items.quantity` (aggregation: `sum`)
* Persistence:
* FullPersistence. This is the most basic/straightforward persistence mode to use.
```aml
pre_aggregates: {
aggregated_quantity: PreAggregate {
dimension pa_created_at {
for: r(demo_orders.created_at)
time_granularity: 'year'
}
measure pa_sum_quantity {
for: r(demo_order_items.quantity)
aggregation_type: 'sum'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
}
```
#### 2. Persist the Pre-Aggregate
Before actually persisting the Pre-Aggregate, we need to create the database schema to store the going-to-be-persisted tables.
In many databases, we can create the schema using a simple SQL:
```sql
CREATE SCHEMA persisted;
```
The schema name that we choose is `persisted`. Make sure it matches the `schema` that you define in the `persistence` of your Pre-Aggregate.
Then, we need to trigger the persistence.
There are 3 ways to trigger a Pre-Aggregate Persistence in Holistics:
* API
* Schedules
* UI
Let's use the UI because it is most convenient for this tutorial:
1. Go to the **List** view of the Dataset
2. Click the Run button on our Pre-Aggregate (`aggregated_quantity`)
3. Confirm
4. Wait for the persistence job to finish
#### 3. Test the exploration
Now when we run the same exploration again, we will see that Holistics automatically uses the aggregated table!
If we run the new Executed Query with `EXPLAIN ANALYZE`, we get this query execution analysis:
```sql
EXPLAIN ANALYZE
SELECT
TO_CHAR((CAST ( "aggregated_quantity"."pa_created_at" AS timestamptz )) AT TIME ZONE 'Europe/London', 'YYYY-MM-DD HH24:MI:SS.US') AS "dy_do_ca_01c493",
MAX("aggregated_quantity"."pa_sum_quantity") AS "s_doi_q_df1043"
FROM
"persisted"."HPA_8d3841d4cbe47c86:f0c246a01792a43a_T1716200122" "aggregated_quantity"
GROUP BY
1
```
We immediately got a huge performance boost: the Database only has to
* Aggregate on **5** rows (10,000 times less data)
* Use **40kB** of memory (20 times less memory)
* Take **0.276ms** in total (> 200 times faster)
## Use case: Sum of Item Quantity sold by Category over Year
### Without Pre-Aggregate
Because of the new dimension `demo_categories.name`, Holistics cannot re-use our existing Pre-Aggregate `aggregated_quantity`,
because `aggregated_quantity` has _coarser_ granularity than our exploration.
If we run the above Executed Query with `EXPLAIN ANALYZE`, we get this query execution analysis:
```sql
EXPLAIN ANALYZE
SELECT
TO_CHAR((CAST ( (DATE_TRUNC ( 'year', (CAST ( "demo_orders"."created_at" AS timestamptz )) AT TIME ZONE 'Europe/London' )) AT TIME ZONE 'Europe/London' AS timestamptz )) AT TIME ZONE 'Europe/London', 'YYYY-MM-DD HH24:MI:SS.US') AS "dy_do_ca_01c493",
"demo_categories"."name" AS "dc_n_8a4e8a",
SUM("demo_order_items"."quantity") AS "s_doi_q_df1043"
FROM
"demo"."order_items" "demo_order_items"
LEFT JOIN "demo"."orders" "demo_orders" ON "demo_order_items"."order_id" = "demo_orders"."id"
LEFT JOIN "demo"."products" "demo_products" ON "demo_order_items"."product_id" = "demo_products"."id"
LEFT JOIN "demo"."categories" "demo_categories" ON "demo_products"."category_id" = "demo_categories"."id"
GROUP BY
1,
2
```
Some highlights: The Database has to
* Aggregate on **54,783** rows
* Take **102.314ms** in total
### Using Aggregate Awareness
#### 1. Define the Pre-Aggregate
In this case, we can update our existing Pre-Aggregate to support more dimensions.
If we look again at the Relationships, we would notice that `Categories` is on the one-side of the relationship with `Products`.
Therefore, we only need to add `demo_products.id` into our Pre-Aggregate and Holistics will take care of the rest!
```aml
pre_aggregates: {
aggregated_quantity: PreAggregate {
dimension pa_created_at {
for: r(demo_orders.created_at)
time_granularity: 'year'
}
// BEGIN new codes
dimension pa_product_id {
for: r(demo_products.id)
}
// END new codes
measure pa_sum_quantity {
for: r(demo_order_items.quantity)
aggregation_type: 'sum'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
}
```
#### 2. Persist the Pre-Aggregate
Re-persist the Pre-Aggregate using the same steps as the first use case:
1. Go to the **List** view of the Dataset
2. Click the Run button on our Pre-Aggregate (`aggregated_quantity`)
3. Confirm
4. Wait for the persistence job to finish
#### 3. Test the exploration
We see that Holistics is able to use our Pre-Aggregate!
If we run the above Executed Query with `EXPLAIN ANALYZE`, we get this query execution analysis:
```sql
EXPLAIN ANALYZE
SELECT
TO_CHAR((CAST ( "aggregated_quantity"."pa_created_at" AS timestamptz )) AT TIME ZONE 'Europe/London', 'YYYY-MM-DD HH24:MI:SS.US') AS "dy_do_ca_01c493",
"demo_categories"."name" AS "dc_n_8a4e8a",
SUM("aggregated_quantity"."pa_sum_quantity") AS "s_doi_q_df1043"
FROM
"persisted"."HPA_8d3841d4cbe47c86:2f77e1908c1b7df2_T1716202922" "aggregated_quantity"
LEFT JOIN "demo"."products" "demo_products" ON "aggregated_quantity"."pa_product_id" = "demo_products"."id"
LEFT JOIN "demo"."categories" "demo_categories" ON "demo_products"."category_id" = "demo_categories"."id"
GROUP BY
1,
2
```
The Database only has to
* Aggregate on **15,289** rows (nearly 4 times less data)
* Take **17.010ms** in total (> 200 times faster)
#### 4. Bonus!
As shown above, Holistics Aggregate Awareness is relationship-aware and can leverage the Dataset Relationships to make the most out of your Pre-Aggregates.
Using the exact same Pre-Aggregate `aggregated_quantity` that we defined earlier, we can also efficiently perform many other explorations such as:
**without** having to add dimensions like `demo_categories.name`, `demo_products.name`, or `demo_merchants.name` into the Pre-Aggregate!
## Use case: Persist Pre-Aggregate using dbt
:::info SQL Dialect Note
This example uses Postgresql SQL Dialect in `persistence.table_name` and in the dbt SQL.
:::
#### 1. Define Holistics Pre-Aggregate
To let Holistics know that you will be handling the persistence yourself, use `ExternalPersistence`:
```aml
pre_aggregates: {
aggregated_quantity: PreAggregate {
dimension pa_created_at {
for: r(demo_orders.created_at)
time_granularity: 'year'
}
dimension pa_product_id {
for: r(demo_products.id)
}
measure pa_sum_quantity {
for: r(demo_order_items.quantity)
aggregation_type: 'sum'
}
// BEGIN new codes
persistence: ExternalPersistence {
table_name: '"persisted"."aggregated_quantity"'
}
// END new codes
}
}
```
#### 2. Persists the Pre-Aggregate using dbt
```sql
{{ config(materialized='table', schema='persisted', alias='aggregated_quantity') }}
SELECT
DATE_TRUNC ( 'year', "demo_orders"."created_at" ) AS "pa_created_at",
"demo_products"."id" AS "pa_product_id",
SUM("demo_order_items"."quantity") AS "pa_sum_quantity"
FROM
"demo"."order_items" "demo_order_items"
LEFT JOIN "demo"."orders" "demo_orders" ON "demo_order_items"."order_id" = "demo_orders"."id"
LEFT JOIN "demo"."products" "demo_products" ON "demo_order_items"."product_id" = "demo_products"."id"
GROUP BY
1,
2
```
## Use case: Sum of Item Quantity sold over Month
Earlier, we pre-aggregated on the `time_granularity` of `year`, so Holistics cannot use that pre-aggregate for aggregations on `month` granulariy.
#### 1. Define the Pre-Aggregate
Now, to pre-aggregate for `month`, here are some options:
1. **Modify** the `time_granularity` of `aggregated_quantity` to `month`
* This makes the Pre-Aggregate less fast for `year` aggregations. However, it should still be very fast.
2. **Create** a new Pre-Aggregate with `time_granularity: 'month'`
* This makes it fast to do aggregations on both `month` and `year`. However, it will cost more storage to store the pre-aggregated data and more maintenance effort.
```aml
pre_aggregates: {
aggregated_quantity: PreAggregate {
dimension pa_created_at {
for: r(demo_orders.created_at)
// BEGIN new codes
time_granularity: 'month'
// END new codes
}
measure pa_sum_quantity {
for: r(demo_order_items.quantity)
aggregation_type: 'sum'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
}
```
```aml
pre_aggregates: {
aggregated_quantity: PreAggregate {
dimension pa_created_at {
for: r(demo_orders.created_at)
time_granularity: 'year'
}
measure pa_sum_quantity {
for: r(demo_order_items.quantity)
aggregation_type: 'sum'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
// BEGIN new codes
aggregated_quantity_by_month: PreAggregate {
dimension pa_created_at {
for: r(demo_orders.created_at)
time_granularity: 'month'
}
measure pa_sum_quantity {
for: r(demo_order_items.quantity)
aggregation_type: 'sum'
}
persistence: FullPersistence {
schema: 'persisted'
}
}
// END new codes
}
```
#### 2. Persist the Pre-Aggregate
Re-persist the Pre-Aggregate using the same steps as the first use case:
1. Go to the **List** view of the Dataset
2. Click the Run button on our Pre-Aggregate (`aggregated_quantity`)
3. Confirm
4. Wait for the persistence job to finish
#### 3. Test the "Create" option
Let's say we go with the option to "Create" a new Pre-Aggregate, this will be the result:
Holistics is also time-granularity-aware. Thus, it can use `aggregated_quantity_by_month` (that we just created) for aggregation on `quarter` granularity as well!
If we try aggregating on `year` again, Holistics can use the initial Pre-Aggregate `aggregated_quantity`: