ScoutQL
ScoutQL is the language reports and Explore are written in. It is a bounded
subset of DuckDB SQL over
Scout’s match data, plus a trailing RENDER clause and four Scout-specific
calls (player, champion, kda, per_minute).
Where ScoutQL overlaps SQL it means exactly what DuckDB means. There are no
conveniences layered on top: AVG(win) is an error in DuckDB and an error
here, aggregates are always written out, and a name in WITH (...) has to be a
name the SELECT produced.
Clause order
Section titled “Clause order”SELECT <expression> [AS <name>] [, …] -- 1 to 20 outputsFROM <source>[WHERE <predicate>][GROUP BY ALL | <expression> [, …]] -- up to 3[HAVING <predicate>][ORDER BY <key> [ASC | DESC] [, …]] -- up to 3[LIMIT <n>][RENDER <kind> [WITH (<option> = <value>, …)]]SELECT and FROM are the only required clauses. A query with no GROUP BY
returns one grand-total row; a query with no RENDER renders as a table.
Keywords are case-insensitive. String values are not — they are compared exactly, as DuckDB compares them.
Comments
Section titled “Comments”-- comments run to the end of the line and may appear anywhere.
-- Ranked solo activity, last four weeks.SELECT COUNT(*) AS gamesFROM match_participantsWHERE queue = 'solo' -- ARAM and Arena excluded AND game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 28 DAYGROUP BY playerORDER BY games DESCOutputs are named, and the name is the handle
Section titled “Outputs are named, and the name is the handle”Every computed output takes an AS name. That name is what HAVING,
ORDER BY, and WITH (...) refer to, what a table column is headed with, and
what a chart series is labelled with.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerHAVING games >= 10ORDER BY win_rate DESCRENDER bar_chart WITH (y = win_rate)WHERE filters rows; HAVING filters groups
Section titled “WHERE filters rows; HAVING filters groups”WHERE is a full boolean expression over the source’s columns — AND, OR,
NOT, and parentheses — evaluated before rows are collapsed. HAVING is a
boolean expression over the aggregates, evaluated after, and may name the
aliases the SELECT defined.
An aggregate in WHERE is rejected rather than reinterpreted, because a
minimum-games floor written there would silently filter individual games
instead of players.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE (queue = 'solo' OR queue = 'flex') AND NOT early_surrendered AND game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 90 DAYGROUP BY playerHAVING games >= 20 AND win_rate > 0.5ORDER BY win_rate DESCTime bounds are ordinary WHERE conditions
Section titled “Time bounds are ordinary WHERE conditions”There is no time clause. A window is a comparison against the source’s own
timestamp column, joined into WHERE with AND like any other condition. The
compiler recognizes the shapes below and executes them as the query’s window;
anything else is applied as a plain filter.
Relative — a rolling window ending now:
SELECT COUNT(*) AS gamesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerCalendar — whole days, in a named zone, both ends inclusive:
SELECT COUNT(*) AS gamesFROM match_participantsWHERE (game_creation_at AT TIME ZONE 'America/Los_Angeles')::DATE BETWEEN '2026-01-01' AND '2026-01-31'GROUP BY playerA calendar bound compares dates, not instants, so the zone has to be stated
before the cast: “January” starts at a different moment in Los Angeles than in
Berlin, and without AT TIME ZONE the days are UTC days.
Each source names the column its window is measured on — see Sources and columns. The rank snapshot sources have no timestamp column at all: a snapshot is current by construction, so there is nothing to bound.
Omitting the bound
Section titled “Omitting the bound”A query with no time condition is legal and covers every match Scout has
ingested. The editor flags it as a warning (time-window-unbounded) without
refusing it.
Both halves of that are deliberate. Refusing would make “how many games have we ever played?” unaskable, and the query is measured — the whole lake is fast to scan. But an unbounded answer read as though it covered the last month is indistinguishable from a correct one, so the warning exists to make the choice visible rather than accidental.
Casts and strings
Section titled “Casts and strings”Strings are single-quoted, and a literal quote is doubled: 'Kai''Sa'.
Double quotes are an error, because DuckDB reads them as identifiers and a
query that meant a value would silently ask for a column.
:: casts, and it is required wherever DuckDB requires it — most often
win::INT, since a boolean has no average until it is a number.
Keywords
Section titled “Keywords”| Keyword | Meaning |
|---|---|
SELECT | Opens the list of outputs. |
FROM | Names the source to read. |
WHERE | Filters rows before aggregation. |
GROUP | With BY, opens the grouping list. |
BY | Follows GROUP or ORDER. |
HAVING | Filters groups after aggregation. |
ORDER | With BY, opens the sort keys. |
LIMIT | Caps the number of rows returned. |
RENDER | Chooses how the result is displayed. |
WITH | Opens the render option list. |
AS | Names an output. |
AND | Boolean conjunction. |
OR | Boolean disjunction. |
NOT | Boolean negation. |
IN | Membership in a parenthesised list. |
IS | Used as IS NULL / IS NOT NULL. |
NULL | The unknown value. |
BETWEEN | Inclusive range test. |
LIKE | Case-sensitive pattern match (% and _). |
ILIKE | Case-insensitive pattern match. |
ASC | Ascending sort. |
DESC | Descending sort. |
DISTINCT | Counts distinct values: COUNT(DISTINCT x). |
FILTER | Narrows one aggregate: FILTER (WHERE …). |
INTERVAL | A duration: INTERVAL 30 DAY. |
CAST | CAST(x AS INT), the long form of ::. |
AT | Opens AT TIME ZONE. |
TIME | Part of AT TIME ZONE. |
ZONE | Part of AT TIME ZONE. |
ALL | GROUP BY ALL, or mentions = all. |
TRUE | Boolean literal. |
FALSE | Boolean literal. |
CURRENT_TIMESTAMP | Now, as a timestamp. |
CURRENT_DATE | Today, as a date. |
CASE | Reserved and rejected — use FILTER (WHERE …) instead. |
Function names, interval units (DAY, WEEK, MONTH, YEAR),
DATE_TRUNC parts, render kinds, and render option names are not keywords.
They are ordinary identifiers resolved by meaning, which is why a column called
day or an output called sort causes no trouble.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Query text length | 16000 characters |
| Outputs per SELECT | 1–20 |
| Grouping expressions | 0–3 |
| ORDER BY keys | 1–3 |
| Default row limit | 10 |
| Maximum rows returned | 25 |
| Chart series | 1–8 |
The maximum row count is a report setting, not query syntax: a LIMIT
larger than the maximum is capped when the report runs.
Vocabulary
Section titled “Vocabulary”Each part of the language has its own page:
- Sources and columns — what
FROM,GROUP BY, and every expression may name. - Functions — what you can compute.
- Filters — what
WHEREandHAVINGaccept. - Render kinds and options — what
RENDERandWITHaccept.
Presets
Section titled “Presets”These are the ready-made queries in the report editor. Each one compiles through the same analyzer as anything you type.
Most games played
Find the most active players over the last 30 days.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerORDER BY games DESCLIMIT 10RENDER leaderboardBest win rate (ranked solo, min 10 games)
Rank players by solo queue win rate with a games floor.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE queue IN ('solo') AND game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerHAVING games >= 10ORDER BY win_rate DESCRENDER bar_chart WITH (y = win_rate)Surrender-happy champions
Spot champions most associated with surrender losses.
SELECT COUNT(*) AS games, AVG(surrendered::INT) AS surrender_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY championORDER BY surrender_rate DESCLIMIT 10RENDER leaderboardMost-played champions
Show which champions the server has been playing most.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY championORDER BY games DESCLIMIT 10RENDER bar_chart WITH (y = games)Most active teammate groups
List teammate groups of every size (group(2) picks duos only) by games together.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM player_groupsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY group(all)ORDER BY games DESCLIMIT 10RENDER leaderboardKDA leaders
Rank players by KDA with a minimum games filter.
SELECT COUNT(*) AS games, kda() AS kdaFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerHAVING games >= 5ORDER BY kda DESCLIMIT 10RENDER leaderboardDamage leaders
Find who dealt the most champion damage.
SELECT COUNT(*) AS games, SUM(total_damage_dealt_to_champions) AS damage_to_championsFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 14 DAYGROUP BY playerORDER BY damage_to_champions DESCLIMIT 10RENDER bar_chart WITH (y = damage_to_champions)Champion-select picks
Use lobby observations to see planned champion picks.
SELECT COUNT(*) AS prematchesFROM prematch_participantsWHERE observed_at >= CURRENT_TIMESTAMP - INTERVAL 14 DAYGROUP BY championORDER BY prematches DESCLIMIT 10RENDER bar_chart WITH (y = prematches)Queue mix
Break recent server activity down by queue.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY queueORDER BY games DESCRENDER tableDaily activity trend
Follow daily game volume with a smoothed filled trend.
SELECT DATE_TRUNC('day', game_creation_at) AS day, COUNT(*) AS gamesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY DATE_TRUNC('day', game_creation_at)ORDER BY day ASCRENDER area_chart WITH (y = games, title = 'Daily games', palette = gold, smooth = true, trend = true, sparkline = true)Weekly wins and losses
Compare weekly wins and losses as stacked bars.
SELECT DATE_TRUNC('week', game_creation_at) AS week, COUNT(*) FILTER (WHERE win) AS wins, COUNT(*) FILTER (WHERE NOT win) AS lossesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 90 DAYGROUP BY DATE_TRUNC('week', game_creation_at)ORDER BY week ASCRENDER stacked_bar WITH (y = (wins, losses), palette = team, labels = value, compare = previous_period)Win/loss share
Show the share of recent games by outcome.
SELECT COUNT(*) AS gamesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY outcomeORDER BY games DESCRENDER donut_chart WITH (y = games, title = 'Recent outcomes', labels = percent)Combat efficiency map
Compare player damage per game with KDA and game volume.
SELECT COUNT(*) AS games, AVG(total_damage_dealt_to_champions) AS damage_per_game, kda() AS kdaFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerHAVING games >= 5ORDER BY damage_per_game DESCRENDER scatter_chart WITH (x = damage_per_game, y = kda, size = games, palette = colorblind)Champion position heatmap
Reveal champion win-rate pockets across team positions.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 60 DAYGROUP BY champion, team_positionHAVING games >= 3ORDER BY games DESCLIMIT 80RENDER heatmap WITH (x = champion, series = team_position, value = win_rate, palette = ranked, labels = value)Champion combat profiles
Compare per-game combat and vision profiles for active champions.
SELECT COUNT(*) AS games, AVG(kills) AS kills_per_game, AVG(assists) AS assists_per_game, AVG(total_damage_dealt_to_champions) AS damage_per_game, AVG(vision_score) AS vision_per_gameFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY championHAVING games >= 5ORDER BY games DESCLIMIT 6RENDER radar_chart WITH (y = (kills_per_game, assists_per_game, damage_per_game, vision_per_game), legend = top, palette = categorical)Server KPI snapshot
Summarize activity, win rate, KDA, and game length in one card.
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rate, kda() AS kda, AVG(game_duration_seconds) AS avg_game_durationFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYRENDER kpi_card WITH (y = (games, win_rate, kda, avg_game_duration), title = '30-day snapshot', theme = minimal_dark)Damage per gold
Rank players by champion damage produced per gold earned.
SELECT COUNT(*) AS games, ROUND(SUM(total_damage_dealt_to_champions) / NULLIF(SUM(gold_earned), 0), 3) AS damage_per_goldFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerHAVING games >= 5ORDER BY damage_per_gold DESCLIMIT 15RENDER bar_chart WITH (y = damage_per_gold, orientation = horizontal, palette = gold, labels = value)Weekly vision trend
Track wards and vision score per game over time.
SELECT DATE_TRUNC('week', game_creation_at) AS week, AVG(vision_score) AS vision_per_game, AVG(wards_placed) AS wards_per_game, AVG(wards_killed) AS wards_killed_per_gameFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 90 DAYGROUP BY DATE_TRUNC('week', game_creation_at)ORDER BY week ASCRENDER line_chart WITH (y = (vision_per_game, wards_per_game, wards_killed_per_game), palette = colorblind, legend = bottom, smooth = true, rolling = 3)Arena placement share
Break Arena games down by final placement.
SELECT COUNT(*) AS arena_gamesFROM match_participantsWHERE queue = 'arena' AND game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY arena_placementORDER BY arena_placement ASCRENDER donut_chart WITH (y = arena_games, palette = ranked, labels = percent)Objective pressure leaders
Combine objective and turret damage into a per-game pressure score.
SELECT COUNT(*) AS games, AVG(damage_dealt_to_objectives + damage_dealt_to_turrets) AS objective_pressureFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerHAVING games >= 5ORDER BY objective_pressure DESCLIMIT 12RENDER bar_chart WITH (y = objective_pressure, orientation = horizontal, palette = team)First blood by position
Compare first-blood rates across team positions.
SELECT COUNT(*) AS games, AVG(first_blood_kill::INT) AS first_blood_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 60 DAYGROUP BY team_positionHAVING games >= 10ORDER BY first_blood_rate DESCRENDER bar_chart WITH (y = first_blood_rate, palette = ranked, labels = percent)Game length trend
Track average match duration by week.
SELECT DATE_TRUNC('week', game_creation_at) AS week, AVG(game_duration_seconds) AS avg_game_durationFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 120 DAYGROUP BY DATE_TRUNC('week', game_creation_at)ORDER BY week ASCRENDER line_chart WITH (y = avg_game_duration, title = 'Average game length', y_axis = 'Minutes', smooth = true, trend = true)Surrender trend
Follow total and early surrender rates by week.
SELECT DATE_TRUNC('week', game_creation_at) AS week, AVG(surrendered::INT) AS surrender_rate, AVG(early_surrendered::INT) AS early_surrender_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 120 DAYGROUP BY DATE_TRUNC('week', game_creation_at)ORDER BY week ASCRENDER area_chart WITH (y = (surrender_rate, early_surrender_rate), palette = colorblind, labels = hide, smooth = true, rolling = 3, compare = previous_period)Game length distribution
Show how recent games are distributed across five-minute length buckets.
SELECT FLOOR(game_duration_seconds / 300) * 300 AS bucket, COUNT(*) AS gamesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY FLOOR(game_duration_seconds / 300) * 300ORDER BY bucket ASCRENDER histogram WITH (x = bucket, y = games, x_axis = 'Game length (seconds)')Activity against the previous period
Overlay weekly game volume with the equally long span before it.
SELECT DATE_TRUNC('week', game_creation_at) AS week, COUNT(*) AS gamesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 56 DAYGROUP BY DATE_TRUNC('week', game_creation_at)ORDER BY week ASCRENDER line_chart WITH (y = games, compare = previous_period, smooth = true)Related
Section titled “Related”- Query recipes — the idioms behind most reports.
- Build your first scheduled report
- Turn a report into a chart