← Files ClickHouseARCHIVED FILE

skills/clickhouse-best-practices/rules/agent-query-safety.md

5.2 KB · Oct 4, 2026 · 12:07 UTC

↓ Download file

---
title: Apply Safety Limits to Agent-Generated Queries
impact: CRITICAL
impactDescription: "Unbounded agent queries can scan billions of rows and saturate cluster resources"
tags: [agent, safety, limits, timeout]
---

## Apply Safety Limits to Agent-Generated Queries

**Impact: CRITICAL**

Every agent-generated query must have explicit safety limits. A single unbounded query can scan billions of rows, consume all memory, or run for minutes.

**Non-negotiable rules:**

- ALWAYS use `LIMIT` to cap returned rows (default `LIMIT 1000`)
- ALWAYS bound scan size with `max_rows_to_read` or `max_bytes_to_read` — `LIMIT` alone does not prevent a full scan
- ALWAYS set `max_execution_time` (default 30)
- NEVER run `SELECT *` on large tables without `LIMIT` and scan caps
- NEVER query without filtering on sort key or partition key columns

**Incorrect:**

```sql
SELECT * FROM events WHERE user_id = '123'
```

**Correct:**

```sql
SELECT *
FROM events
WHERE event_date >= today() - 7 AND user_id = '123'
LIMIT 100
SETTINGS max_execution_time = 30,
         max_rows_to_read = 1000000000,
         timeout_before_checking_execution_speed = 0
```

**Recommended per-query settings:**

| Setting | Recommended | Effect |
|---------|-------------|--------|
| `max_rows_to_read` | 1e9 | Caps rows scanned before materialization — the real guardrail |
| `max_bytes_to_read` | 1e11 | Caps bytes scanned |
| `max_execution_time` | 30 | Interrupts query when projected execution time exceeds N seconds (see `timeout_before_checking_execution_speed`) |
| `timeout_before_checking_execution_speed` | 0 | Makes `max_execution_time` behave as a wall-clock limit (default `10` gives queries 10s of grace before timeouts kick in) |
| `max_estimated_execution_time` | 60 | Rejects queries whose projected runtime exceeds N seconds — kills expensive queries before they start |
| `max_result_rows` | 10000 | Caps output rows |
| `result_overflow_mode` | `'break'` | Returns partial result of ≥ `max_result_rows`, rounded up to the next block boundary (it does not truncate exactly) |

Limits are checked at block boundaries, so actual scans and runtime can overshoot slightly.

**Cloud vs self-hosted defaults that matter:**

| Setting | Self-hosted default | Cloud default |
|---------|---------------------|---------------|
| `max_memory_usage` | `0` (unlimited) | Depends on replica RAM — not unlimited |
| `max_bytes_before_external_group_by` | `0` (no spill) | Half the memory per replica — spills automatically |
| `max_bytes_before_external_sort` | `0` (no spill) | Half the memory per replica — spills automatically |
| `max_rows_to_read` / `max_bytes_to_read` | `0` (unlimited) | `0` (unlimited) — must be set explicitly on both |
| `max_execution_time` | `0` (unlimited) | `0` (unlimited) — must be set explicitly on both |

On self-hosted, GROUP BY and ORDER BY have no automatic memory ceiling — set the `max_bytes_before_external_*` settings explicitly or enforce via profile. On Cloud, GROUP BY / ORDER BY spill to disk automatically and per-query memory is bounded, but scan and execution-time caps are still your job.

**When things go wrong:**

- **Timeout** (`TIMEOUT_EXCEEDED`): Narrow the time range, add sort key filters, run `EXPLAIN ESTIMATE` to check scan size before retrying. Consider `max_estimated_execution_time` to reject expensive queries up front.
- **Memory error** (`MEMORY_LIMIT_EXCEEDED`): Reduce actual memory use — narrow filters, add `LIMIT`, lower GROUP BY cardinality, enable `max_bytes_before_external_group_by` (already on by default in Cloud, off on self-hosted), or split into smaller time windows. Raising `max_memory_usage` only helps if you're authorized and the ceiling is genuinely the problem; *lowering* it makes the error happen sooner, not later.
- **Too many parts** (`TOO_MANY_PARTS`): Back off inserts — merges are behind. Wait and retry.

**Role-level hardening (belt-and-suspenders):**

Per-query `SETTINGS` only applies if the agent remembers to emit it. For production, the primary mechanism should be a [settings profile](https://clickhouse.com/docs/operations/settings/settings-profiles) plus [`readonly=2`](https://clickhouse.com/docs/operations/settings/constraints-on-settings#read-only) on the agent's role, so limits apply even when the agent forgets. Per-query settings are then defense in depth, not the fence.

Per-query limits also don't stop abuse via many small queries — use [quotas](https://clickhouse.com/docs/operations/quotas) to bound requests or scanned bytes per interval.

**Progressive exploration pattern:**

Start narrow, widen only if needed:

```sql
-- 1. Count first (cheap)
SELECT count() FROM events WHERE event_date = today();

-- 2. Small sample (if count is reasonable)
SELECT * FROM events WHERE event_date = today() LIMIT 10;

-- 3. Full query with LIMIT and scan caps
SELECT user_id, count() as events
FROM events
WHERE event_date = today()
GROUP BY user_id
ORDER BY events DESC
LIMIT 100
SETTINGS max_execution_time = 30,
         max_rows_to_read = 1000000000,
         timeout_before_checking_execution_speed = 0;
```

Reference: [Query complexity restrictions](https://clickhouse.com/docs/operations/settings/query-complexity) · [Query-level settings](https://clickhouse.com/docs/operations/settings/query-level)

SHA-256: 332b8bc9ceac7064a9424cf25b84bc84de3e23218f2c617bb1d62488cfaea7b5