---
title: "Execute a read-only SQL query"
method: POST
path: "/v1/query"
tags: ["query"]
---

# Execute a read-only SQL query

`POST /v1/query`

## Request

- Base URL: `https://data.thinair.co`
- URL: `https://data.thinair.co/v1/query`
- Auth: API key in header `x-api-key`

Runs a SELECT / WITH / EXPLAIN against the target connection. Read-only enforcement runs at three layers (sql-guard, session READ ONLY, optional per-connection firewall) below this handler — writes are rejected with `code: SYNTAX` or `PERMISSION_DENIED` and never reach the upstream DB. Costs 1 quota unit per successful call.

## Request body

- QueryRequest: Execute a read-only SQL statement (SELECT / WITH / EXPLAIN). Read-only enforcement runs at three layers below this endpoint (sql-guard, session READ ONLY, optional per-connection firewall); writes are rejected upstream of the REST layer.
  - `query` string, required: Read-only SQL statement (SELECT / WITH / EXPLAIN only). Multiple statements not allowed; sql-guard runs additional checks before execution.
  - `connection` string: Connection name or id from this tenant's inventory. Falls back to the tenant's default (first added) when omitted. Mirrors the MCP `query_sql` tool's `connection` arg.
  - `limit` integer: Max rows to return (default 100, max 1000). Mirrors `query_sql.limit`.

## Response `200`

Query result.

- QueryResponse: Result of a SQL execution. `rows.length` may be less than `meta.row_count` when truncation applied — clients re-issue with a higher `limit` (up to 1000) to see more.
  - `rows` object[], required: Result rows. Each row is a `{column: value}` map. `null` is used for SQL NULL; numbers, booleans, strings, arrays, and JSON objects flow through as their JS shape.
  - `columns` string[], required: Column names from the first row's keys (or `[]` when `rows` is empty). Service-layer `executeQuery` returns row dicts without a separate column list; deriving from the first row matches the MCP envelope's behavior.
  - `meta` QueryMeta, required
    - `upstream_ms` integer, required: Wall time spent inside the service-layer call (origin DB round-trip plus pool wait).
    - `row_count` integer, required: Total rows the underlying query produced (BEFORE truncation). When `truncated` is true, this is greater than `rows.length`.
    - `truncated` boolean, required: True when the result was capped at `applied_limit` rows.
    - `dialect` 'postgres' | 'mysql' | 'mssql', required: Resolved connection dialect — write follow-up SQL in this dialect's syntax (PostgreSQL `LIMIT`, T-SQL `TOP N`, MySQL `LIMIT`).
    - `connection` string, required: Connection name (display, not id) the query ran against.
    - `applied_limit` integer, required: Effective `limit` after clamping (default 100, max 1000).
    - `cache_hit` boolean: True when the result came from the per-tenant query cache rather than the database.
    - `tables` string[], required: Tables referenced by the SQL (best-effort parse).
    - `retries` integer: Transient-retry count. Omitted on first-shot success.
    - `total_wait_ms` integer: Cumulative wall-time spent in retry backoff. Omitted when `retries` is unset.

## Other responses

- `400`: Invalid body, SQL syntax error, or unknown table/column.
- `401`: Authentication required or upstream DB auth failed.
- `403`: Permission denied on the upstream DB (READ ONLY transaction, missing role grants).
- `404`: No connections configured for tenant, or connection name not found.
- `409`: Database deadlock — safe to retry (see `retry_after_ms`).
- `429`: Quota exceeded, rate limited, or pool saturated.
- `502`: Origin DB unreachable, refused, or TLS handshake failed.
- `503`: Backpressure — circuit breaker open or per-tenant queue timeout.
- `504`: Query exceeded the plan's `execute_timeout_ms`.

---

[API](https://skmtc.dev/thinair/apis/thinair-data-api.md) · [All operations](https://skmtc.dev/thinair/apis/thinair-data-api/llms.txt) · [OpenAPI document](https://skmtc.dev/thinair/apis/thinair-data-api/revisions/8a2a2d1b2400?raw)
