DocsGuides

Guides

SQL performance and troubleshooting

4 min readReviewed July 2026

GraphJSON exposes ClickHouse SQL over event collections, but the notebook is still an interactive product surface. A query that scans unnecessary history, repeatedly parses JSON, or builds a huge join can be logically correct and operationally unsuitable.

Use this guide when a query is slow, fails, returns no rows, exceeds the result limit, or produces a chart that does not match the table.

Know the boundaries#

  • SQL is read-only; mutations are blocked.
  • Results receive an outer limit of 2,000 rows.
  • The notebook waits up to 15 minutes for a query job.
  • JSON properties are extracted at query time.
  • Wide time ranges, high-cardinality grouping, and large joins increase work.
  • GraphJSON may throttle sustained rates of unusually expensive unique queries.

A 15-minute maximum is a failure boundary, not a target. Dashboard and embed queries should normally be much faster.

Start with the smallest proof#

When a complex query fails, reduce it:

SELECT timestamp, json
FROM product_events
WHERE timestamp >= now() - INTERVAL 1 HOUR
LIMIT 10

Then add one step at a time:

  1. event filter
  2. extracted fields
  3. grouping
  4. aggregation
  5. join
  6. window function
  7. final ordering

Run after each step. The first failing step identifies the relevant class of problem.

Filter time and event name early#

Poor:

SELECT
  JSONExtractString(json, 'plan') AS plan,
  count()
FROM product_events
GROUP BY plan

Better:

SELECT
  JSONExtractString(json, 'plan') AS plan,
  count() AS signups
FROM product_events
WHERE
  timestamp >= now() - INTERVAL 30 DAY
  AND JSONExtractString(json, 'event') = 'signup_completed'
GROUP BY plan
ORDER BY signups DESC

Always decide whether “all retained history” is genuinely required.

Extract once#

For a field used repeatedly, extract it in a CTE:

WITH events AS (
  SELECT
    timestamp,
    JSONExtractString(json, 'event') AS event,
    JSONExtractString(json, 'account_id') AS account_id,
    JSONExtractString(json, 'plan') AS plan
  FROM product_events
  WHERE timestamp >= now() - INTERVAL 30 DAY
)
SELECT
  plan,
  uniqExact(account_id) AS active_accounts
FROM events
WHERE event = 'feature_used'
GROUP BY plan
ORDER BY active_accounts DESC

This improves readability and reduces the chance that two copies use different paths or types. Whether the query engine eliminates every repeated expression is an implementation detail; the clearer contract is still valuable.

Select only required columns#

Avoid SELECT * in production notebooks. It:

  • returns large JSON payloads
  • makes table results noisy
  • couples the query to future columns
  • can hide which fields the metric actually needs

Name the result contract explicitly.

Control cardinality#

High-cardinality fields include:

  • user and account IDs
  • request IDs
  • raw URLs
  • free-form errors
  • timestamps
  • search text

Grouping by one can produce more than 2,000 rows and create unreadable charts.

Choose one:

  • filter to a known entity
  • normalize the property upstream
  • group by a stable category
  • return only the top values
  • aggregate at a coarser grain

Example:

SELECT
  JSONExtractString(json, 'route_template') AS route,
  count() AS requests
FROM api_events
WHERE
  JSONExtractString(json, 'event') = 'api_request_completed'
  AND timestamp >= now() - INTERVAL 7 DAY
GROUP BY route
ORDER BY requests DESC
LIMIT 50

Use /accounts/:id rather than raw /accounts/acct_123.

Aggregate before joining#

Poor:

SELECT ...
FROM product_events p
JOIN billing_events b
  ON JSONExtractString(p.json, 'account_id') =
     JSONExtractString(b.json, 'account_id')

This can join every product event for an account to every billing event for the same account.

Better:

WITH activity AS (
  SELECT
    JSONExtractString(json, 'account_id') AS account_id,
    count() AS product_events
  FROM product_events
  WHERE
    timestamp >= now() - INTERVAL 30 DAY
    AND JSONExtractString(json, 'account_id') != ''
  GROUP BY account_id
),
plans AS (
  SELECT
    JSONExtractString(json, 'account_id') AS account_id,
    argMax(
      JSONExtractString(json, 'plan'),
      timestamp
    ) AS current_plan
  FROM billing_events
  WHERE JSONExtractString(json, 'account_id') != ''
  GROUP BY account_id
)
SELECT
  plans.current_plan,
  sum(activity.product_events) AS product_events
FROM activity
INNER JOIN plans USING account_id
GROUP BY plans.current_plan
ORDER BY product_events DESC

Each side becomes one row per join key before the join.

Use exact distinct counts deliberately#

uniqExact is useful when exact unique entities matter. Exact sets can consume more work than approximate distinct functions on very large populations.

For business-critical counts within ordinary product datasets, exactness may be the right choice. For exploratory high-volume telemetry, decide whether an approximate result is acceptable and document the choice.

Never switch to an approximate function merely to make a slow dashboard pass without telling readers that the definition changed.

Keep percentiles scoped#

Percentiles are valuable for latency and size distributions. Filter to:

  • relevant event
  • time window
  • route or service when appropriate
  • valid numeric values
SELECT
  quantile(0.95)(
    JSONExtractFloat(json, 'duration_ms')
  ) AS p95_duration_ms
FROM api_events
WHERE
  JSONExtractString(json, 'event') = 'api_request_completed'
  AND timestamp >= now() - INTERVAL 24 HOUR

Do not calculate a percentile across unrelated endpoints and then label it API latency without explaining the population.

Return a chart-ready shape#

Time series#

Return one row per bucket:

SELECT
  toUnixTimestamp(toStartOfHour(toDateTime(timestamp))) AS hour,
  count() AS failures
FROM webhook_events
WHERE
  JSONExtractString(json, 'event') = 'webhook_delivery_completed'
  AND JSONExtractInt(json, 'status_code') >= 500
  AND timestamp >= now() - INTERVAL 7 DAY
GROUP BY hour
ORDER BY hour

Category chart#

Return one label and value per row:

SELECT
  JSONExtractString(json, 'provider') AS provider,
  count() AS failures
FROM webhook_events
WHERE
  JSONExtractInt(json, 'status_code') >= 500
  AND timestamp >= now() - INTERVAL 7 DAY
GROUP BY provider
ORDER BY failures DESC

If the table is correct and the chart is wrong, check the notebook’s x-axis, value, label, and split mappings.

Diagnose empty results#

Check in this order:

  1. collection name and case
  2. time range and timestamp units
  3. event name and case
  4. field path
  5. value type
  6. empty string versus missing value
  7. environment
  8. retention

Inspect Samples, then run:

SELECT
  JSONExtractString(json, 'event') AS event,
  count() AS rows
FROM product_events
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY event
ORDER BY rows DESC
LIMIT 100

This often reveals a naming or environment mismatch.

Diagnose type errors#

GraphJSON commonly uses:

  • JSONExtractString
  • JSONExtractInt
  • JSONExtractFloat
  • JSONExtractBool

Use the function that matches the producer contract. If the same property arrives as multiple types, repair the producer and isolate the affected interval. Avoid a growing tower of silent coercions.

Money should be a numeric minor-unit field plus currency. Timestamps used as event time belong in the request’s Unix-seconds timestamp.

Diagnose duplicate or inflated totals#

Check:

  • at-least-once retries
  • joins that multiply rows
  • multiple state-change events per object
  • multiple users counted as accounts
  • overlapping categories
  • a backfill overlapping live delivery

Count stable IDs:

SELECT
  count() AS rows,
  uniqExact(JSONExtractString(json, 'event_id')) AS unique_events
FROM product_events
WHERE
  JSONExtractString(json, 'event') = 'invoice_paid'
  AND timestamp >= now() - INTERVAL 30 DAY

If rows exceed unique events, follow Deduplicate mutable business events.

Query review checklist#

  • Time range is bounded.
  • Event filters are applied before expensive work.
  • Repeated JSON fields are named once.
  • Only required columns are selected.
  • High-cardinality groups are bounded.
  • Both sides are aggregated before large joins.
  • Result stays below 2,000 rows.
  • Chart mappings match result types.
  • Money and time units are documented.
  • Exact versus approximate calculations are intentional.
  • The query description states owner and exclusions.

For a query still exceeding interactive expectations, send GraphJSON the redacted SQL shape, collection sizes, time range, and approximate runtime. Never include the API key or customer payloads.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support