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 instructeer

Python client library — instructeer on GitHub →

Authentication

Pass your API key in the X-API-Key header.

X-API-Key: rg_your_key

Endpoint

POST/sql/v1/analyze

Analyze a SQL query for unbounded execution risk.

FieldTypeNotes
sqlstringRequired. The SQL query to analyze.
dialectstringOptional. 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.

RuleSeverityTriggers onReason
ddl_blockedhighDROP, ALTER, TRUNCATE, CREATESchema changes should never come from an LLM
dcl_blockedhighGRANT, REVOKEPermission changes must never be LLM-generated
dml_no_wherehighDELETE or UPDATE without WHEREAffects every row in the table
cartesian_joinhighJOIN without ON clause, CROSS JOINCross product — result set grows exponentially
limit_too_largehighLIMIT / TOP / FETCH NEXT > 10,000Prevents runaway result sets and cost overruns
where_always_truemediumWHERE TRUE, WHERE 1=1, WHERE 1<>0Disables filtering — possible injection pattern
select_star_no_limitmediumSELECT * without LIMITFull table scan, unbounded result set
join_count_highmedium / high3–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+ joins

Flagged — 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 200

Allowed — 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