Query
Query lets you answer product questions with read-only SQL. The dashboard editor and the Public API run the same engine against your app data.
Where to find it
Open Dashboard → Analytics → Query.
Use Instructions in the editor for a copyable SQL reference. Save queries as presets for your team.
Virtual tables
Query exposes three virtual tables. App scoping is injected automatically. You never filter by app_id yourself.
events
| Column | Type | Notes |
|---|---|---|
timestamp | timestamptz | Event time |
user_id | text | Anonymous user identifier |
name | text | Event name (e.g. purchase, level_win) |
params | text | JSON string of event properties |
events queries run on QuestDB when used alone. Use QuestDB JSON syntax:
json_extract(params, '$.duration_seconds')
cast(json_extract(params, '$.level_number') as long)Modulo is supported: expr % 5 = 0
users
| Column | Type | Notes |
|---|---|---|
user_id | text | |
platform | text | ios, android, or unknown |
country | text | ISO country code |
locale | text | |
model | text | Device model. Apple hardware IDs (e.g. iPhone17,1) are resolved to marketing names (e.g. iPhone 16 Pro). Android and already-friendly names pass through. |
os_version | text | |
first_seen | timestamptz | First seen time |
properties | jsonb | Custom user properties — use properties->>'key' |
The virtual table is named users. Do not use devices or device_id in SQL. Those names are rejected.
sessions
| Column | Type | Notes |
|---|---|---|
session_id | text | |
user_id | text | |
started_at | timestamptz | |
last_activity_at | timestamptz | |
duration_seconds | number | Computed session length |
Each query uses one virtual table. events runs on QuestDB. users and sessions run on Postgres.
Time range
If your SQL does not filter on timestamp, started_at, last_activity_at, or first_seen, events and sessions default to the last 30 days.
Add your own predicates to control the window:
WHERE timestamp >= '2026-01-01'Pagination
Pagination is page-based, not OFFSET in SQL.
| Surface | Page size | Max page size | Pagination |
|---|---|---|---|
| Dashboard | LIMIT in SQL (default 100) | 1000 | UI page controls |
| Public API | LIMIT in SQL (default 100) | 50 | page in request body |
Rules:
LIMITsets how many rows each page returns.- Do not write
OFFSETin SQL. It is rejected. - Use page controls in the dashboard, or send
pagein the API request. - Deep pagination is capped at 100,000 skipped rows.
- Export downloads the current page only.
API example with pagination:
{
"sql": "SELECT name, count(*) AS events FROM events GROUP BY name ORDER BY events DESC LIMIT 50",
"page": 2
}The response includes meta.page, meta.pageSize, meta.offset, meta.hasNextPage, and meta.hasPreviousPage so you can loop programmatically.
Rules
- SELECT only. No
INSERT,UPDATE,DELETE, or DDL. - Single statement. No semicolons.
- Read-only sandbox. Queries cannot mutate data.
- Debug events excluded automatically.
- One table per query. No
JOINand no multi-tableFROM. - Legacy names rejected:
devices,device_id.
Examples
Recent events:
SELECT timestamp, user_id, name AS event_name
FROM events
ORDER BY timestamp DESC
LIMIT 100Top events:
SELECT name AS event_name, count(*) AS events
FROM events
GROUP BY name
ORDER BY events DESC
LIMIT 100Users by platform:
SELECT platform, count(*) AS users
FROM users
GROUP BY platform
ORDER BY users DESC
LIMIT 50Average level duration (every 5th level):
SELECT
count(*) AS level_wins,
avg(cast(json_extract(params, '$.duration_seconds') AS double)) AS avg_duration_seconds
FROM events
WHERE name = 'level_win'
AND cast(json_extract(params, '$.level_number') AS long) % 5 = 0Presets
Save the current SQL as a preset from the Query page. Presets are per app and shared with your team. Loading a preset replaces the editor contents.
Automation
Use the same queries from your backend with a public API key. See Query via API for the endpoint and request shape. Full SQL, pagination, and limit details live on this page.