optimizing-clickhouse-and-hogql-queries — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited optimizing-clickhouse-and-hogql-queries (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Optimizes ClickHouse and HogQL queries (HogQL compiles to ClickHouse), not Postgres / Django ORM. For an app-DB query (Model.objects.filter(...)), stop and use pganalyze plus the Postgres section of query-performance-optimization.md; Step 0 has the full triage.
Work from the ClickHouse SQL, not the HogQL. Get the ClickHouse SQL the query produces, optimize that, then translate the change back into the HogQL query, query runner, printer, or a migration. Reasoning about HogQL alone hides what ClickHouse executes.
Assumes you can write HogQL. For new queries from scratch use /writing-clickhouse-queries; for migration mechanics, /clickhouse-migrations.
When the job is "optimize all of team X's queries," build the full inventory first or you'll miss some.
/establishing-code-ownership. Owned paths span backend Python _and_ frontend/src/..., often across several products; don't silently narrow to one product or to the backend.*QueryRunner, execute_hogql_query, raw sync_execute), but plenty are built client-side and POSTed to /query. Grep frontend/ for:api.queryHogQL(...), HogQLQueryString, the ` hogql... ` templateNodeKind.HogQLQuery / kind: 'HogQLQuery' with a query: stringDataTableNode, EventsQuery, TrendsQuery/InsightVizNode, PropertyFilterType.HogQLSELECT ... FROM events, or product markers ('survey sent', $survey_id)The same query is sometimes implemented twice (backend runner plus frontend HogQL, often a stalled migration). Both are in-scope; a backend printer/function fix won't reach a hand-built frontend string.
Check how the query is built:
| What you see | Where it goes | What to use |
|---|---|---|
execute_hogql_query(...), HogQLQuery, a *QueryRunner, an insight, a HogQL .ambr | ClickHouse via the HogQL printer | This skill |
sync_execute(...), client.execute(...), hand-written SELECT ... FROM events, a migration | ClickHouse directly (no HogQL) | This skill (steps 2-5; skip step 1) |
Model.objects.filter(...), .raw(...), a queryset, RawSQL over the app DB | Postgres via Django ORM | Not this skill. query-performance-optimization.md (## PostgreSQL) + pganalyze |
personhog_client.*, get_personhog_client(), get_person_by_* | personhog (gRPC, Postgres) | Not this skill. posthog/personhog_client/README.md |
If you were pointed at a coordinator / orchestrator / Celery task / Temporal workflow / management command, the ClickHouse query is usually one layer in (the dispatched activity, child workflow, or apply_async target). Follow the dispatch to the layer that builds the HogQL. A file may mix Postgres (pick work) and HogQL (do work); treat them as separate tasks.
If the slow query is raw ClickHouse SQL in production code (f-strings / strings to sync_execute, client.execute, client.read_query, not printer output), flag it, then continue. The printer gives HogQL materialized-column substitution, property-group dispatch, lazy joins, and team-id guards for free; raw SQL reimplements or loses each. The structural fix is to express it in HogQL, but that's larger; offer both options (local fix now, HogQL move later).
Single-team vs multi-team decides whether raw SQL is excusable. execute_hogql_query is team-scoped (one team, injects the team_id guard).
team_id = X query that's hand-written leaks materialization, lazy joins, and the team-id guard. Treat raw single-team sync_execute as a smell. Clearest tell: a hand-rolled materialized-column lookup (e.g. get_materialized_column_for_property(...) with a JSONExtract fallback), which is the printer's job done by hand.team_id or a team_id IN (...)) can't use execute_hogql_query; keep them raw and optimize in place. A hand-rolled materialized-column lookup is expected here, not a smell.So: one team, recommend HogQL; many teams, optimize the raw SQL directly.
`INSERT` doesn't exempt the read half. HogQL has no INSERT, so build the SELECT in HogQL, print it, and concatenate into INSERT INTO <table> <printed_select> (only the wrapper is raw). A single-team INSERT ... SELECT with a hand-written SELECT (raw columns, JSONExtract over properties, manual team_id) is the same smell: move the SELECT to HogQL, keep the INSERT INTO wrapper raw. Genuinely raw-fine: INSERT ... VALUES of Python rows, multi-team INSERT ... SELECT, migrations, one-shot scripts.
Handbook (conceptual model): query-performance-optimization.md (finding/fixing slow queries), hogql-python.md (printer pipeline, driving HogQL), clickhouse-queries-new-products.md (table/runner design).
Table schemas, skim for ORDER BY / PARTITION BY / INDEX / materialized columns (don't read line-by-line):
posthog/models/event/sql.pysessions_v3.py (v2: sessions_v2.py)posthog/models/person/sql.py; overrides: person_overrides/sql.pycohortpeople membership): products/cohorts/backend/models/sql.py. The Postgres Cohort definition (cohort.py) is a Postgres concern (Step 0).app_metrics2, session_replay_events, log_entries, heatmaps, product tables): find under posthog/models/*/sql.py or the migration that created them.HogQL side: query entry query.py; printers clickhouse.py / base.py; helpers utils.py; functions posthog/hogql/functions/ (aggregations in aggregations.py); schema posthog/hogql/database/schema/.
Materialization (auto-rewrites property access away from JSONExtract):
ee/clickhouse/materialized_columns/columns.py, get_materialized_columns(table) / get_enabled_materialized_columns(table), cached 15 min against the connected ClickHouse.posthog/clickhouse/property_groups.py._get_materialized_property_source_for_property_type() / visit_property_type() in base.py (~1260, ~1354), ClickHouse override in clickhouse.py (~412). On each property access the printer emits the best form available (direct column, property group, or JSONExtract fallback) for the connected ClickHouse.This is why the same HogQL prints differently in test (sparse materialization) vs prod (dense): the lookup runs against the connected ClickHouse. Assume printer-path property access gets materialized; only hand-written SQL that bypasses the printer must do its own lookup.
Cluster topology (shards, replicas, ingestion vs data nodes): posthog/clickhouse/migrations/CLAUDE.md, read before proposing any migration.
Raw ClickHouse query? You already have the SQL; skip to Step 2. For HogQL, three ways:
execute_hogql_query() (query.py) gives response.clickhouse. SQL only, no execute: prepare_and_print_ast(..., dialect="clickhouse") (utils.py); for a prepared AST, print_prepared_ast..ambr files under posthog/hogql_queries/test/__snapshots__/ (and per-product test dirs) hold generated ClickHouse SQL for representative inputs./query-clickhouse-via-metabase against clusterAllReplicas(posthog, system, query_log) (or posthog.query_log_archive for rows older than ~4h; ~22 day retention, typed lc_* columns, so prefer it). Filter is_initial_query, type = 'QueryFinish', query_duration_ms > <threshold>.Eyeball the ClickHouse SQL for these before reaching for tools. To instead work backwards from a specific slow query's runtime cost (bytes vs CPU vs duration, high-cardinality breakdowns, function-wrapped sort keys, ratio-metric double scans, tracing to source code, EXPLAIN), see references/investigation-playbook.md.
FROM <table> FINALFINAL on a ReplacingMergeTree / CollapsingMergeTree / AggregatingMergeTree (person, groups, cohortpeople, …) forces an on-the-fly merge across every part read, deduplicating to the latest version per sort-key row. It defeats parallel reads, blows up memory, scales badly with part count; rarely right on large/sharded tables. Rewrites:
SELECT properties FROM person FINAL WHERE ... id IN (...) becomes SELECT argMax(properties, version) FROM person WHERE ... id IN (...) GROUP BY id.PostHog-specific: per CLAUDE.md, new person/group access should go through personhog (get_personhog_client), not raw person / groups. A raw FROM person FINAL in new code usually wants the personhog move, not tuning.
JSONExtractString/Float(...), JSONHas(...), etc. against raw properties / person_properties / group_properties parse the JSON blob per row: up to ~100x slower than a directly materialized (mat_* / dmat_*) column, ~10x slower than a property group read.
*Printer-path queries (backend `parse_select` / `execute_hogql_query` / `QueryRunner, frontend api.queryHogQL / hogql... ): replace every JSONExtract(properties, 'X')` with `properties.X` (wrap in `toFloat(...)` / `toInt(...)` for non-strings). Convert _all_ of them; don't work out what's materialized. The printer does the lookup at print time and emits the best form (materialized column, property group, DMAT slot, or `JSONExtract` fallback). `properties.X` is never worse* than the hand-written form, and improves automatically when a column is later materialized.
Don't cherry-pick by reading migrations / the registry / `DESCRIBE`, which reimplements the printer and gets it wrong: materialization often isn't from a migration, property groups have no dedicated column, and the set varies per environment and over time. Partial conversion leaves the query inconsistent for zero benefit. Convert all; let the printer decide.
Exception, raw SQL that bypasses the printer (multi-team sync_execute, migrations, hand-built temporal activity strings): no printer and no properties.X syntax, so reference the materialized column directly. Correct there, and only there. Strategies (for that case and background; not needed for printer-path): directly materialized materialized_columns.py; property groups property_groups.py; DMAT slots posthog/models/dmat_slot_assignments/ plus EVENTS_TABLE_DYNAMICALLY_MATERIALIZED_COLUMNS() in event/sql.py. The new ClickHouse JSON type is being trialed; check recent migrations.
In tests, materialize a property for the block with the materialized() context manager in posthog/test/base.py (create_minmax_index, create_bloom_filter_index, lower-case variants).
A JSONExtract in a .ambr snapshot whose source uses properties.X is just the test fixture's fallback (prod may emit a materialized read). Not a bug; don't "fix" it.
Check the WHERE covers an ORDER BY prefix. The events sort key is (team_id, toDate(timestamp), event, cityHash64(distinct_id), cityHash64(uuid)), so non-trivial events queries should filter timestamp and event unless there's a documented reason (e.g. cohort calc needing all events).
Assert skip-index use with get_index_from_explain / get_indexes_from_explain (posthog/test/base.py, run EXPLAIN PLAN indexes=1, json=1) so a printer change can't silently undo it. Common reasons an index isn't used: a nullIf/wrapper hiding the materialized column, a comparison against stringified NULL, or the column missing from fixtures (use materialized(..., create_minmax_index=True)).
Joining events (or any large table) to itself doubles the work and loses primary-key ordering. Rewrite to one pass plus conditional aggregation: sumIf(amount, event='purchase'), uniqIf(distinct_id, event='pageview'), uniqMapIf(...). For correlated rows ("first event before a conversion"), arrayFilter / arrayFirst / window functions over an ordered groupArray beat a self-join. Missing aggregation function? Add it to aggregations.py.
ClickHouse WITH name AS (SELECT ...) CTEs are inlined, not materialized: referenced twice means executed twice, and nesting multiplies out. Most common cause of "the planner is doing something weird." Until WITH ... AS MATERIALIZED ships (check CH release notes), rewrite to a single pass with conditional aggregation, or force one execution via a FROM subquery.
EXPLAIN works on dev ClickHouse without representative data (planner output doesn't need rows):
EXPLAIN PLAN indexes=1, actions=1, json=1 SELECT ... for primary key + skip index useEXPLAIN QUERY TREE SELECT ... for the post-analyzer logical treeEXPLAIN PIPELINE SELECT ... for the processor pipelineEXPLAIN ESTIMATE SELECT ... for per-part row/mark estimatesEXPLAIN SYNTAX SELECT ... for the normalized SQLClickHouse EXPLAIN docs. For the side-by-side diff technique (suspect vs fixed variant, diffing Granules / ReadType / Prewhere-vs-primary-key), see references/investigation-playbook.md.
EXPLAIN shows intent; to know if a rewrite is faster, run both versions against representative data.
Local ClickHouse (correctness and EXPLAIN, not timing, which is too noisy): hogli dev:demo-data seeds synthetic data; hogli db:ch opens a client. You _can_ try skip indexes / materialized columns / schema changes locally (single node), but ask the user first, and remember prod is multi-node so structural changes must round-trip through /clickhouse-migrations; ALTER TABLE ... MATERIALIZE INDEX ... builds a new index over existing data. Bytes-read (FORMAT JSON, or local system.query_log) is a less-noisy proxy than wall time.
Test Cluster (timing): Metabase-fronted, read-only snapshot of team 2's data, no noisy neighbors. Use /query-clickhouse-via-metabase. Adapt the prod query first: swap team_id, pick an overlapping date range, substitute/skip branches that depend on properties/features team 2 lacks (judgement call). Apply prod materialized columns before timing: DESCRIBE <table> lists real pmat_* (events) / pmat_* / mat_* (persons, groups); swap JSONExtract(...) for them or you're timing a shape the printer never emits. Set SETTINGS use_uncompressed_cache=0, take the median of 5, and read query_duration_ms / read_rows / read_bytes / memory_usage / ProfileEvents from system.query_log, not the Metabase request time.
Measure before suggesting. For any rewrite, run original vs candidate on the same team-2-adapted query and report before/after query_duration_ms, read_bytes, memory_usage. A suggestion without numbers is a guess; if you couldn't measure (cluster unavailable, doesn't adapt, schema-only change), say so. The cluster is read-only, so prototype schema changes locally.
Autoresearch (powertool) for hard cases: tools/query-performance-ai/ wraps pi-autoresearch to optimize against the Test Cluster in a loop. Setup is non-trivial (Docker sandbox, ANTHROPIC_API_KEY, Metabase DB IDs); have the user run it. See its README and coordinator.
Make HogQL emit the faster ClickHouse SQL at the lowest-blast-radius layer:
posthog/hogql_queries/ or products/*/backend/; snapshot via .ambr.aggregations.py (or the right file in functions/) with HogQLFunctionMeta(name, min_args, max_args, aggregate=True)._get_optimized_materialized_column_equals_operation (~line 574, clickhouse.py) is a template. Add a snapshot test plus a get_index_from_explain assertion./clickhouse-migrations; prod is multi-shard/replica with data vs ingestion roles, so node_roles=[...], sharded=True, is_alter_on_replicated_table=True matter; never ON CLUSTER. Clean example: 0250_property_values_lowercase_text_index.py.Some rewrites help one team and hurt another (a funnels rewrite was great with a big step-1-to-2 drop-off but slower when step 1 matched almost every event). For such asymmetries, suggest a runtime heuristic (count events per step, apply only when the ratio is favorable) rather than committing it speculatively; the shape is the user's call.
When you change a printer rule / query runner / add a function, snapshot the generated ClickHouse SQL in .ambr and add an EXPLAIN-based assertion if the win depends on a specific index or rewrite. Green-after-fix isn't proof; flip the change off to confirm the test exercises your path.
references/learnings.md has case studies where a smell here needed nuance; read before leaning hard on one, and append what you measure. No customer data (public repo): no raw person/group/distinct_id values, custom property names/values, team/org names, row samples, or precise scale. Use placeholders (<custom_property>, <team_id>) or shapes (tens of millions of rows). Team 2 is fine to name; redact other team IDs.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.