Development

Designing Agent Tools That Query the Database Without Full Scans

Antonio Evans8 min read

A database table drawn as a grid of rows, with a highlighted index path (a narrow lane) running through it.
Key points

Key points

  1. 1Agent-written SQL is only as safe as the tool schema that constrains it, so require the columns that match your index before the model writes any SQL.
  2. 2Cap every range parameter (for example, 31 days) and append a server-side LIMIT so a single tool call can never request the whole table.
  3. 3Validate generated SQL against EXPLAIN before execution, and reject plans that show a sequential scan on a large table with a message that names the missing predicate.
  4. 4Where no index fits, fall back to pre-aggregated views, smaller summary tables or a scoped export instead of letting the agent improvise.

Why the index decision moves into the tool schema

When a human writes SQL, a reviewer can rewrite a bad statement. When an agent writes SQL, the statement does not exist until runtime, so the only place you can guarantee selective predicates is the interface the model is forced to speak through. The planner picks a sequential scan whenever it estimates no usable restriction on an indexed column, or when the constant covers too large a slice of the table, as documented for MySQL's optimizer. Postgres behaves the same way: a WHERE name = ... against an unindexed text column produced a parallel sequential scan over roughly 560,000 rows in the Crunchy Data walkthrough of EXPLAIN.

That gives you the design rule. You cannot control the shape of every statement, but you can control the arguments. If the tool only accepts tenant_id plus a bounded date range, the agent physically cannot produce a query without a leading predicate. Index design follows the same logic: start from the access pattern, then put equality columns first and the range or sort column last, which is the leftmost prefix rule described in this composite index analysis. For an orders table that means an index on (tenant_id, created_at), and a tool whose schema requires both fields.

The payoff is large and measurable. In the issue tracker demo, adding a composite index moved a filtered, sorted query from a 16.6ms sequential scan to a 0.04ms index scan because all three conditions landed in the Index Cond and the sort disappeared. You want agent traffic to hit that second plan every time, not sometimes.

Write the tool schema so the index is mandatory

Treat the JSON schema as the contract that guarantees an indexable predicate. Use required for the columns that lead your index, enum where the value set is closed, and maximum/minimum on numeric ranges.

{
  "name": "list_orders",
  "description": "List orders for one tenant within a bounded date window. Always call this instead of writing SQL.",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["tenant_id", "created_from", "created_to"],
    "properties": {
      "tenant_id": { "type": "string", "format": "uuid" },
      "created_from": { "type": "string", "format": "date" },
      "created_to": { "type": "string", "format": "date" },
      "status": { "type": "string", "enum": ["new", "paid", "shipped", "cancelled"] },
      "min_total": { "type": "number", "minimum": 0 }
    }
  }
}

Three details matter more than the field names. First, required is doing the index enforcement, so never mark the leading column optional "for flexibility". Second, additionalProperties: false stops the model inventing a filter column that has no index. Third, describe the cap in the tool description itself, because the model reads that text and will often self-correct before your server has to reject it.

Validate the range on the server, not in the schema alone. JSON Schema cannot express "no wider than 31 days" across two fields cleanly, so compute it in code after parsing. A model that receives a clear rejection learns the bound faster than one that receives a generic error.

Build the query on the server, not in the model

Do not accept SQL text from the model when a parameterized builder will do. The builder receives validated arguments, emits a fixed statement shape, and applies the caps itself. This is the same discipline as static query verification: sqlc verify warns at build time when a query will produce a sequence scan, and you want the runtime equivalent.

WINDOW = timedelta(days=31)
HARD_LIMIT = 500

def build_orders_query(p):
    start, end = p["created_from"], p["created_to"]
    if end < start or (end - start) > WINDOW:
        raise ToolError("date_range_too_wide", max_days=31, got_days=(end - start).days)
    sql = """
      SELECT id, tenant_id, status, total, created_at
      FROM orders
      WHERE tenant_id = %(tenant_id)s
        AND created_at >= %(start)s
        AND created_at <  %(end)s
    """
    params = {"tenant_id": p["tenant_id"], "start": start, "end": end}
    if "status" in p:
        sql += " AND status = %(status)s"
        params["status"] = p["status"]
    sql += " ORDER BY created_at DESC LIMIT %(limit)s"
    params["limit"] = HARD_LIMIT
    return sql, params

The upper bound is exclusive (< rather than <=) so boundaries never double count, and the LIMIT is appended by the server, which means the model cannot widen it. Note that min_total is accepted by the schema but deliberately not pushed into SQL here: a filter on an unindexed column adds a heap check per row without narrowing the index scan. Either add it to a partial index or leave it out.

The decision rule for which parameters to push into SQL: only parameters whose columns appear in the index you intend the planner to use, plus columns you fetch anyway. Everything else is filtered after the bounded result set comes back, or promoted to a real index if agents ask for it often enough.

Cap ranges and limits before the planner sees them

A date range is the easiest parameter for a model to widen. Asked for "orders from the last few months", a helpful agent may send a two year window, and a two year window on a large table is a sequential scan wearing a costume. The cap is what makes the predicate selective in practice, not just in theory.

ControlWhere it livesWhat it preventsFailure it returns
Required leading columnJSON schema requiredQueries with no indexable predicateSchema validation error
Max range widthServer code, checked after parsingWide scans that touch most of the tabledate_range_too_wide with the allowed window
Hard LIMITAppended by the builderUnbounded result sets and memory spikesSilent truncation plus a flag
Closed enum for statusSchema enumFilters on non-indexed or mistyped valuesSchema validation error
EXPLAIN gatePre-execution checkPlans that ignore the indexNamed missing predicate
Timeout and row capConnection settingsRunaway queriesCancelled statement

Two more server-side settings are worth wiring up because they backstop everything above. Set a statement timeout on the role the agent uses, and consider default_transaction_read_only for read tools so a malformed call cannot write. PgBouncer tracks default_transaction_read_only by default, per the PgBouncer release notes, and transaction pooling keeps agent connections cheap since a server connection is only held for the duration of a transaction (PgBouncer features). If your agent runs many short tool calls, transaction pooling mode is the recommended configuration for exactly this traffic pattern (Heroku's PgBouncer guide).

Return truncation explicitly. If the builder added LIMIT 500 and the result contains exactly 500 rows, tell the model "truncated": true and include the window that was used, so it narrows the range instead of assuming it has the full picture.

Validate against EXPLAIN before you execute

The cap makes a bad plan unlikely, not impossible. Statistics can be stale, a tenant can genuinely dominate a table, or an index can be missing in one environment. Run EXPLAIN on the built statement and inspect the plan before executing the real query. Postgres emits a plan tree of scan nodes, and you can request machine-readable JSON output specifically so a program can inspect it (PostgreSQL EXPLAIN documentation).

def check_plan(conn, sql, params, big_tables={"orders", "events", "audit_log"}):
    with conn.cursor() as cur:
        cur.execute("EXPLAIN (FORMAT JSON) " + sql, params)
        plan = cur.fetchone()[0][0]["Plan"]
    for node in walk(plan):
        if node["Node Type"] == "Seq Scan" and node.get("Relation Name") in big_tables:
            raise ToolError(
                "plan_rejected",
                reason="sequential_scan",
                table=node["Relation Name"],
                hint="No usable predicate on an indexed column for this table.",
            )

Keep that check to EXPLAIN without ANALYZE on the request path, because ANALYZE actually runs the statement (Crunchy Data on EXPLAIN ANALYZE). Use EXPLAIN ANALYZE in tests and in the nightly suite, where you can afford to execute, and where you get actual row counts and timings to compare against estimates, as described in this guide to reading query plans. The Datadog writeup on debugging latency with EXPLAIN ANALYZE makes the same split: plans in production, plans plus measurements during investigation.

One caution on EXPLAIN alone: a small table legitimately scans sequentially, and Postgres can choose a scan when the filter matches most rows. Allow-list large tables rather than banning every sequential scan, or you will block correct queries on lookup tables and burn tokens on retries that cannot succeed.

Write rejection messages the model can act on

A rejection that says "query rejected" teaches the model nothing and it will retry the same shape. Name the missing predicate, give the allowed values, and say what to do next. This is the highest-leverage string in the whole system.

{
  "error": "plan_rejected",
  "reason": "sequential_scan",
  "table": "orders",
  "missing_predicate": "tenant_id",
  "allowed": {
    "date_range_max_days": 31,
    "status": ["new", "paid", "shipped", "cancelled"]
  },
  "next_step": "Call list_orders again with tenant_id set and created_from/created_to inside a 31 day window."
}

Compare that with {"error": "invalid request"}. The first tells the model exactly which argument closes the window on the index and how wide the range may be. The second invites another guess, another rejected plan, and a latency hit on every attempt. Include the observed problem in the message: the table that was scanned, whether the range was too wide, or whether the LIMIT was missing. Agents converge quickly when the error payload contains the constraint.

Log every rejection with the tool name, the arguments, and the plan node that triggered it. That log is your index backlog: when the same missing predicate appears for a column repeatedly, add the index or add the filter to the builder. Do not let agents "learn" around a missing index by trying variations; fix the index once.

When no index fits: fallbacks that stay honest

Some questions genuinely have no single-table index behind them: "how did revenue trend by week for the last year", "which customers have the most refunds". Forcing these through a row-level read tool either produces a slow sequential scan or a stream of rejected plans. Pick a fallback designed for the question instead of loosening the gate.

Question shapeBad fitBetter fallback
Long-range trendWide date range on ordersRead a pre-aggregated daily or weekly rollup table
Cross-tenant rankingNo tenant predicateOffline job writes a leaderboard snapshot the tool reads by date
Fuzzy text searchLIKE '%term%' on a text columnFull-text or trigram index, exposed as its own tool
Headline countsCOUNT(*) over a wide rangeMaterialized count refreshed on a schedule
Bulk exportThousands of paged callsOne job that writes a file and returns a handle

The rollup table is usually the cheapest win. A daily aggregate keyed by (tenant_id, day) is small, has an obvious index, and answers a year-long trend with a bounded range that the same range cap can express. If agents keep needing a column that is not in your index, that is a signal to change the index or the rollup, not to widen the tool. Note too that Postgres can answer a query from the index alone when every referenced column is stored in it, which removes the heap fetches and can turn a borderline scan into a fast path (PostgreSQL index-only scans). Designing a covering index for your most common agent tool is often easier than designing three.

For teams wiring several tools and several model providers behind one endpoint, routing every tool call through a single gateway keeps the schemas, caps and rejection format identical across models; that is what Quanta Inference is built to do. When agents need SQL-writing tools built against a specific schema and index set, an expert from Quanta Skills can scope it with you rather than you hand-rolling each builder.

A worked example with numbers

Take orders with 40 million rows, 12,000 tenants, and an index on (tenant_id, created_at). The average tenant has about 3,300 rows, so a tenant_id equality plus a 31 day window is a handful of index entries.

  • Without the predicate: sequential scan over 40 million rows. Using the ratio from the Crunchy Data example (about 560,000 rows filtered in roughly 130ms of scan time), that is tens of seconds and a large amount of shared buffer churn on every call.
  • With tenant_id and a 31 day window: index scan touching roughly a few thousand entries at most, then a LIMIT 500 on top. Sub-millisecond to a few milliseconds.
  • With tenant_id but a two year range: the index cond still applies to tenant_id, so the plan stays an index scan, but the agent receives mostly the same 500 rows it would have received anyway, now read across a much wider index range. The cap wastes work and hides rows the agent needed from the middle of the range.
  • With the EXPLAIN gate on: the wide-range call is allowed (it is an index scan) but the response is flagged truncated, and the model is told to narrow the window. The no-tenant call is rejected before execution with missing_predicate: tenant_id.

The lesson from the fourth case is that plan checks and range caps cover different failures. The plan check catches missing predicates. The range cap and truncation flag catch predicates that exist but are too broad. You need both, which is why the release notes for connection poolers and query verifiers keep pointing at the same split between what you validate statically and what you cap at runtime.

FAQ

FAQ

Should the agent ever be allowed to write raw SQL?

Only behind a read-only role, a statement timeout, and the same EXPLAIN gate, and only for tables you have explicitly allow-listed. For most products the parameterized builder covers the useful queries and is far easier to keep on indexes.

Why require a leading column instead of just checking EXPLAIN?

The schema stops the bad query before it is generated, which saves a round trip to the database and a retry loop in the agent. The EXPLAIN check is the backstop for cases the schema cannot express, such as a range that is valid but too wide for the current statistics.

Does a LIMIT make a sequential scan cheap?

No. Without a sort, the database may stop early, but with an ORDER BY it often has to read and sort a large set before applying the limit, so the LIMIT alone does not save you. The selective predicate is what keeps the work small, which is why the schema requires it.

What range cap should I pick?

Pick the widest window a typical user request needs, then halve it and measure. A 31 day cap suits order and event data where users work month by month. For audit logs or time series, weekly caps with a rollup fallback for longer trends usually work better.

How do I know which predicate is missing from the rejection?

Read the plan node that triggered it. Postgres names the relation in the sequential scan node, and the filters that were applied appear as Index Cond versus Filter, so a condition showing up under Filter on a large table is the one that needs an index or needs to move into the tool arguments.

Sources

Sources

  1. 10.2.1.23 Avoiding Full Table Scans
  2. Explaining Your Postgres Query Performance | Crunchy Data Blog
  3. PostgreSQL Multi-Column Indexes Column Orders
  4. From 17ms to 0.04ms: How to Design the Right SQL Index
  5. Prevent full table scans in production
  6. PgBouncer - lightweight connection pooler for PostgreSQL
  7. PgBouncer features
  8. PgBouncer Configuration and Best Practices - Heroku Dev Center
  9. Documentation: 18: 14.1. Using EXPLAIN - PostgreSQL
  10. Reading a Postgres EXPLAIN ANALYZE Query Plan - Thoughtbot
  11. Debug PostgreSQL query latency faster with EXPLAIN ANALYZE in ...
  12. 18: 11.9. Index-Only Scans and Covering Indexes
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles