SQLGuard
Analyzes SQL queries for unbounded execution risk before they reach your database. Catches queries with no WHERE clause, no LIMIT, cartesian joins, and other patterns that cause full table scans or runaway result sets. AST-based — parses query structure, not raw text, so reformatting or aliasing does not bypass detection.
Designed for LLM-to-SQL pipelines where the model has no knowledge of your data volume. A query that looks reasonable in development can scan millions of rows in production. On cloud databases that charge per byte scanned, that is a cost problem as well as a performance one.
pip install instructeerPython client library — instructeer on GitHub →
Authentication
Pass your API key in the X-API-Key header.
X-API-Key: rg_your_keyEndpoint
/sql/v1/analyzeAnalyze a SQL query for unbounded execution risk.
| Field | Type | Notes |
|---|---|---|
| sql | string | Required. The SQL query to analyze. |
| dialect | string | Optional. postgres · mysql · bigquery · tsql · snowflake and more. Defaults to postgres. Full dialect list → |
from instructeer.guards import SQLGuard
# api_key = os.environ["INSTRUCTEER_API_KEY"]
sql = SQLGuard(api_key="rg_your_key")
result = sql.analyze("DELETE FROM users", dialect="postgres")
if not result.allowed:
raise ValueError(f"Query blocked: {result.severity}")Response Format
severity reflects the worst issue found.allowed is false only for high severity — your code decides how to handle medium. Multiple rules can fire on a single query.
High — blocked
{
"allowed": false,
"severity": "high",
"issues": [
{
"rule": "dml_no_where",
"severity": "high",
"message": "DELETE without WHERE clause would affect all rows"
}
]
}Medium — flagged
{
"allowed": true,
"severity": "medium",
"issues": [
{
"rule": "select_star_no_limit",
"severity": "medium",
"message": "SELECT * without LIMIT — unbounded result set"
}
]
}Low — allowed
{
"allowed": true,
"severity": "low",
"issues": []
}allowed: false only when severity is high. Medium severity returns allowed: true with issues populated — inspect severity and decide in your own code whether to proceed.
Detection Rules
Eight rules applied on every query. All run on the parsed AST — not the raw SQL string.
| Rule | Severity | Triggers on | Reason |
|---|---|---|---|
| ddl_blocked | high | DROP, ALTER, TRUNCATE, CREATE | Schema changes should never come from an LLM |
| dcl_blocked | high | GRANT, REVOKE | Permission changes must never be LLM-generated |
| dml_no_where | high | DELETE or UPDATE without WHERE | Affects every row in the table |
| cartesian_join | high | JOIN without ON clause, CROSS JOIN | Cross product — result set grows exponentially |
| limit_too_large | high | LIMIT / TOP / FETCH NEXT > 10,000 | Prevents runaway result sets and cost overruns |
| where_always_true | medium | WHERE TRUE, WHERE 1=1, WHERE 1<>0 | Disables filtering — possible injection pattern |
| select_star_no_limit | medium | SELECT * without LIMIT | Full table scan, unbounded result set |
| join_count_high | medium / high | 3–7 joins (medium), 8+ joins (high) | Complex joins are expensive and slow |
Examples
All examples below run on the parsed AST — reformatting or aliasing does not bypass detection.
Blocked — High severity
DROP TABLE sessions # DDL blocked
ALTER TABLE users ADD COLUMN age INT # DDL blocked
TRUNCATE TABLE logs # DDL blocked
GRANT ALL PRIVILEGES ON users TO public # DCL blocked
REVOKE SELECT ON users FROM public # DCL blocked
DELETE FROM users # no WHERE clause
UPDATE accounts SET balance = 0 # no WHERE clause
SELECT * FROM users, orders # cartesian join
SELECT * FROM users CROSS JOIN orders # cartesian join
SELECT * FROM users LIMIT 1000000 # limit exceeds 10,000
SELECT TOP 50000 id FROM orders # limit exceeds 10,000 (tsql)
SELECT * FROM a JOIN b ON a.id = b.a_id
JOIN c ON b.id = c.b_id JOIN d ON c.id = d.c_id
JOIN e ON d.id = e.d_id JOIN f ON e.id = f.e_id
JOIN g ON f.id = g.f_id JOIN h ON g.id = h.g_id # 8+ joinsFlagged — Medium severity (allowed: true, inspect issues)
SELECT * FROM users # no limit
SELECT * FROM orders WHERE status = 'pending' # no limit
SELECT * FROM users WHERE TRUE # always-true condition
SELECT * FROM users WHERE 1 = 1 # always-true condition
SELECT * FROM a
JOIN b ON a.id = b.a_id
JOIN c ON b.id = c.b_id
JOIN d ON c.id = d.c_id # 3 joins
-- Real-world: 6 joins + CTE + window functions, WHERE + LIMIT present
-- allowed: true, severity: medium — inspect join count in your code
WITH la AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) rn FROM customer_addresses)
SELECT c.customer_id, o.order_id, o.status, p.product_name, oi.quantity, oi.unit_price,
SUM(oi.quantity * oi.unit_price) OVER (PARTITION BY o.order_id) AS order_total
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
LEFT JOIN la ON la.customer_id = c.customer_id AND la.rn = 1
LEFT JOIN order_items oi ON oi.order_id = o.order_id
LEFT JOIN products p ON p.product_id = oi.product_id
LEFT JOIN invoices inv ON inv.order_id = o.order_id
LEFT JOIN payments pay ON pay.invoice_id = inv.invoice_id
WHERE o.order_date >= '2024-01-01'
ORDER BY o.order_date DESC LIMIT 200Allowed — Safe patterns
SELECT id, email FROM users WHERE id = 1
SELECT * FROM users LIMIT 100 # limit present
DELETE FROM sessions WHERE user_id = 42 # WHERE present
UPDATE users SET last_login = NOW() WHERE id = 1 # WHERE present
INSERT INTO events (type, ts) VALUES ('login', NOW())
SELECT * FROM users WHERE TRUE AND id = 1 # real condition present
SELECT u.id, o.total
FROM users u
JOIN orders o ON u.id = o.user_id # 1-2 joins
WHERE o.created_at > '2024-01-01'Allowed — Complex queries and dialect-specific syntax
-- CTEs and subqueries pass cleanly
WITH top_users AS (SELECT id FROM users WHERE created_at > '2024-01-01')
SELECT * FROM top_users LIMIT 50
SELECT u.email FROM users u
WHERE u.id IN (SELECT user_id FROM orders WHERE total > 1000) LIMIT 100
-- Analytics with GROUP BY and window functions
SELECT COUNT(*), AVG(amount) FROM orders
WHERE created_at >= '2024-01-01' AND status = 'completed'
GROUP BY DATE(created_at) LIMIT 30
-- CTE + window functions, 2 joins — passes cleanly
WITH la AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) rn FROM addresses)
SELECT c.customer_id, o.order_id,
SUM(oi.quantity * oi.unit_price) OVER (PARTITION BY o.order_id) AS order_total
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
LEFT JOIN la ON la.customer_id = c.customer_id AND la.rn = 1
WHERE o.order_date >= '2024-01-01'
ORDER BY o.order_date DESC LIMIT 200
-- Dialect-specific syntax (postgres)
SELECT id::text FROM users WHERE email ILIKE '%@example.com' LIMIT 50
INSERT INTO users (email) VALUES ('x@y.com') RETURNING id, created_at
-- T-SQL
SELECT TOP 10 id, email FROM users ORDER BY created_at DESC
SELECT ISNULL(phone, 'N/A') FROM users WITH (NOLOCK) WHERE id = 1
-- Snowflake
SELECT id, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY created_at) AS rn
FROM users QUALIFY rn = 1 LIMIT 100
-- BigQuery
SELECT id, tag FROM users, UNNEST(tags) AS tag WHERE id = 42 LIMIT 100
-- Oracle
SELECT id, email FROM users WHERE ROWNUM <= 50 ORDER BY id
-- SQLite
SELECT rowid, id, typeof(value) AS val_type FROM config WHERE rowid < 100
-- Hive
SELECT id, tag FROM users LATERAL VIEW EXPLODE(tags) tmp AS tag WHERE id = 1 LIMIT 100