ScoutQL recipes
Use these when you know what you want to ask and need the shape that asks it. Each recipe is a complete query: paste it into the report editor, change the column and the window, and run it.
They are also the recipes Scout’s own AI assistants are given, and the snippets the editor offers while you type — one list, so the answer you get from the editor and the answer you get from the assistant cannot disagree.
Before you start, open the report editor (Reports → New report in your
server’s dashboard) so the live preview can run each one against your own
match history. Everything below assumes match_participants, which is one row
per tracked player per finished match.
Win rate
A rate is the average of a boolean cast to an integer. DuckDB will not average a BOOLEAN, so the `::INT` is required — and it is what makes the value a percentage rather than a count.
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 leaderboardConditional aggregate with FILTER
`FILTER (WHERE …)` narrows one aggregate without narrowing the query, so wins, losses, and a solo-queue win rate can share a single pass over the same rows.
SELECT COUNT(*) AS games, COUNT(*) FILTER (WHERE win) AS wins, COUNT(*) FILTER (WHERE NOT win) AS losses, AVG(win::INT) FILTER (WHERE queue = 'solo') AS solo_win_rateFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerMedians and percentiles
`MEDIAN(x)` and `QUANTILE_CONT(x, q)` describe a distribution's shape where an average hides it — a 90th-percentile damage number answers "how big are the big games?".
SELECT MEDIAN(total_damage_dealt_to_champions) AS median_damage, QUANTILE_CONT(total_damage_dealt_to_champions, 0.9) AS p90FROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY championORDER BY p90 DESCLIMIT 10Distinct counts
`COUNT(DISTINCT x)` counts unique values — champion pool size, distinct queues played. It is not additive, so it cannot be accumulated across buckets.
SELECT COUNT(*) AS games, COUNT(DISTINCT champion_id) AS championsFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerORDER BY champions DESCPer-minute rates and KDA
`per_minute(x)` divides a summed counter by minutes played, guarding a zero denominator; `kda()` is takedowns over at-least-one death, so a deathless streak still ranks by takedowns.
SELECT COUNT(*) AS games, per_minute(creep_score) AS cs_per_minute, kda() AS kdaFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY playerORDER BY cs_per_minute DESCA rolling time bound
A relative bound is an ordinary WHERE conjunct. Omitting it is legal and means every ingested game, which is rarely the question — state a window unless you mean all history.
SELECT COUNT(*) AS gamesFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 7 DAYGROUP BY playerORDER BY games DESCA calendar month, in a real time zone
Calendar bounds compare dates, not instants: shift the timestamp into the zone the days are measured in, cast to DATE, and BETWEEN two ISO dates (both ends inclusive).
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE (game_creation_at AT TIME ZONE 'America/Los_Angeles')::DATE BETWEEN '2026-01-01' AND '2026-01-31'GROUP BY playerA weekly trend
Time series bucket with `DATE_TRUNC` in GROUP BY and echo the same expression in SELECT so the bucket has a name to plot and sort by.
SELECT DATE_TRUNC('week', game_creation_at) AS week, COUNT(*) AS gamesFROM 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 = games, smooth = true)This period against the last
`compare = previous_period` re-runs the same aggregation over the equally long span immediately before the window and overlays it. It needs a stated window and a time bucket to line the two up.
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), compare = previous_period)A distribution histogram
Bucket a numeric column with `FLOOR(x / width) * width` and count rows per bucket. The multiplication keeps the bucket's real starting value, so the axis reads in the column's own units.
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)One player, one champion
`player('…')` resolves a tracked player's accounts at run time, and `champion('…')` folds a display name to its numeric id at compile time (misspellings get a suggestion).
SELECT COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE player('Bob') AND champion_id = champion('Jinx') AND game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 90 DAYGROUP BY patchORDER BY patch ASCA five-number summary
Box plots take their five outputs explicitly, in the order min, q1, median, q3, max — the same explicit-SQL bargain as the rest of the language, so nothing is guessed from a column name.
SELECT champion, MIN(kills) AS low, QUANTILE_CONT(kills, 0.25) AS q1, MEDIAN(kills) AS med, QUANTILE_CONT(kills, 0.75) AS q3, MAX(kills) AS highFROM match_participantsWHERE game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 30 DAYGROUP BY championLIMIT 8RENDER box_plot WITH (y = (low, q1, med, q3, high))Combining them
Section titled “Combining them”The recipes compose. A weekly trend of one player’s solo-queue win rate, with a
minimum of five games a week, is the rolling window, the boolean average, the
DATE_TRUNC bucket, and a HAVING floor in one query:
SELECT DATE_TRUNC('week', game_creation_at) AS week, COUNT(*) AS games, AVG(win::INT) AS win_rateFROM match_participantsWHERE player('Bob') AND queue = 'solo' AND game_creation_at >= CURRENT_TIMESTAMP - INTERVAL 180 DAYGROUP BY DATE_TRUNC('week', game_creation_at)HAVING games >= 5ORDER BY week ASCRENDER line_chart WITH (y = win_rate, x_axis = 'Week', smooth = true)If a recipe returns nothing
Section titled “If a recipe returns nothing”Work down this list before rewriting the query:
- The window is shorter than the activity. A 7-day window on a quiet server is empty and correct. Widen it.
- A
HAVINGfloor is too high.HAVING games >= 10removes every player in a week nobody played ten games. - A queue filter excludes everything. Queue values are listed in Queues and regions, and they are compared exactly.
- The source has no rows for those players. Reports read matches Scout ingested after the players were tracked — there is no backfill of history from before that.
Related
Section titled “Related”- ScoutQL — the language itself.
- Functions — everything you can compute.
- Turn a report into a chart