01 · Multi-database supportPathStart here →

Every major database, one interface

Tier 1 types get every feature, are tested against a real server in CI and sit under the compatibility promise. Experimental types work, with documented gaps. The same guard, pooling, parameter binding and output formats apply to every one; what each supports is in the support matrix.

The support matrix →
QueryAPIGate ~ databases · the support matrix at a glance
Tier 1
PostgreSQLMySQLSQLiteDuckDBClickHouseMongoDB (find)
Experimental
H2Generic JDBC: Oracle, SQL Server, DB2, Snowflake…Trino
Files, through DuckDB
Parquet · CSV · JSONS3 · GCS · R2 · HTTPS · disk

Trino Exp: one connection reaches every catalog behind a Trino endpoint, Hive, Iceberg and Delta tables included, with the same guard, masking and limits. Guide 61 →

Pooling and time limits

Idle connections kept per connection, QUERYAPIGATE_POOL_SIZE, and a query time limit enforced on every front door (on every database type but generic JDBC), lowered per key or per request with ?timeout.

Guide 37 →

TLS to the database

PostgreSQL's sslmode and MySQL's ssl_* settings are passed to the driver: a connection that asks for a verified certificate fails rather than connecting without one.

No passwords in the store

Passwords as ${VAR} references or encrypted at rest under a key you can rotate; or short-lived credentials from Vault or RDS IAM Exp.

Guide 56 →

Files as tables

Point a connection at a folder or bucket of Parquet, CSV or JSON and each file becomes a view through DuckDB, locked to the paths that connection allows.

Guide 41 →
02 · Security and access controlPathGive a partner an API →

Access control, per key, decided before any query runs

Built to expose specific query results, not database credentials. A key identifies an application; every grant below narrows what it reaches, and writes are off by default. Hand an external client a key that can only ever reach one named query while internal callers keep connection-level access.

The eight stages every request passes, step by step →
QueryAPIGate ~ security · every control a key carries

Whole connection

connections

Every saved query on it, plus ad-hoc SQL if allowed.

Curated endpoints

queriescollections

Name the saved queries a key may run, one at a time or as a collection. Nothing else answers.

Table allow-list

allowed_tables

Ad-hoc SQL confined to named tables; the SQL is parsed, not pattern-matched.

Column masking

mask

Hash, mask, null or drop named source columns. Traced through aliases, CTEs and joins; refused rather than served unmasked.

Write granularity

allowed_write_ops

Writes off by default. Turn on only INSERT, or UPDATE on one query.

Budget and origin

rate_limitallowed_ips

A per-key request budget on top of the server-wide, IP-keyed limit (a request has to pass both), and a list of IPs or CIDR ranges the key may call from.

Lifecycle

expires_atlast_used_at

Time-boxed keys for trials and contractors; alerts before expiry and for keys gone unused.

Signed-in users

Authorization: Bearer

Accept the JWT your app already has (Auth0, Cognito, Keycloak, Azure AD or your own secret). Each user calls as themselves, mapped to a role.

Fair shares

max_concurrentquery_timeoutmax_rows

Cap the queries a key runs at once, how long each may run and the rows a call returns, across every instance. Over it: 429 too_many_concurrent.

Usage quotas

quota

Calls and rows per day or month, with X-Quota-Remaining-* headers on every response and an alert before it runs out.

Secret rotation

rotate

Reissue a key's secret while the old one keeps working for a grace period, or zero for a leaked one.

Administrators and sign-in

owner · admin · developer · auditor

The people and pipelines that manage the server are named, with the narrowest role that fits, so every change in the audit log has a person behind it.

Single sign-on and the break-glass key →

Keys and passwords at rest

API keys are stored as SHA-256 hashes, never the raw secret. Database passwords are encrypted under QUERYAPIGATE_SECRET_KEY and decrypted only in memory when a connection opens; a literal password on disk is never returned to a client either way.

Security →

Scoped keys

Restrict a key to a set of connections, to an allow-list of saved queries independent of any connection grant, or both. A query in that list can be curated for write access without granting it anywhere else the key reaches.

Guide 1 →

Named permission roles

A reusable template of connections, write access, queries, collections, rate limit and allowed IPs, copied onto a key once at creation. Editing or deleting a role afterwards never affects a key already created from it.

Collections

File saved queries into named groups and grant a key a whole group, live. Every move that changes who can reach a query is audited with the keys that gained or lost access. Rename without any key losing reach; export a bundle and import it elsewhere.

Guide 7 →

Workspaces EXP

Several teams on one server, each with its own connections, saved queries, keys, roles, destinations and exports, and its own history, audit entries, alerts and events. Every route also answers at /w/<workspace>/…; a key works in its own, and a workspace's owner manages its members. ADR 0023 →

The SQL guard

Only single SELECT, WITH, SHOW, DESCRIBE and EXPLAIN statements pass by default, with bound :name parameters rather than string-concatenated SQL. Ad-hoc SQL, where a key is allowed it, is confined to allowed_tables and recorded like any saved query.

Guide 16 →
03 · MCP server for AI agentsPathAgents →

Let an agent query production data without handing it a database connection

The same saved queries are tools an AI agent can call over the Model Context Protocol, served by a dedicated process, queryapigate mcp. It is the same governed core as REST: an agent gets the same identity, grants, column masking, limits and audit as any app, enforced in the gateway and never asked of the model.

The MCP server reference →
QueryAPIGate ~ mcp · what an agent sees

One tool per published query

What an agent sees in tools/list: the parameters, their types, bounds and an example, and the tables the query reads, so each argument means something rather than being just a name.

tools/listread-only
{
  "name": "example_top_films",
  "description": "Top films by rentals in a category",
  "inputSchema": {
    "properties": {
      "category": {"type": "string", "enum": ["Comedy", "Drama", "Action", …]},
      "top_n":    {"type": "integer", "minimum": 1, "maximum": 50, "default": 10}
    }
  },
  "annotations": {"readOnlyHint": true}
}
// the same query that answers GET /q/example_top_films over REST

Self-describing tools

Each published query is an MCP tool carrying its parameters, their types, descriptions and an example, and the tables it reads. A query can be hidden from MCP with mcp: false while it keeps serving over REST.

Read-only by default; writes with approval

The ad-hoc execute_sql, list_tables and describe_table tools are forced read-only whatever the key's own write grant. An agent's writes are held as requests and run only once a person approves them, with exactly the arguments shown.

Masking-aware metadata

describe_table and schema discovery honour column masking: a masked or dropped column is flagged, never shown as readable, so an agent cannot even learn that hidden data exists.

Standard OAuth

The MCP server is an OAuth 2.0 protected resource (RFC 9728). An agent signs in through your identity provider, and the token's audience is bound to the MCP server's own URL, so it cannot be replayed against REST or the database.

Every call recorded

MCP runs land in the same run history, per-key metrics and tamper-evident audit log as every other caller.

Connect a client

Claude Desktop, Cursor, LangChain or any MCP client: point it at the server, sign in, and the published queries appear as tools.

Guide 24 →

Semantic search Exp: a saved query takes an embed parameter, and the agent's words become a vector for pgvector to rank. Search by meaning →

04 · Saved, versioned queries

From a SQL statement to a documented endpoint

A saved query bundles SQL, name, description, parameters, author, version, permissions and optional cache settings into one addressable resource, GET /q/<name>. Saving again under the same name creates a new version rather than overwriting the old one, so a change to the SQL never silently changes what is already live; ?version=1 still runs the prior one, and every run is recorded in the query's history.

QueryAPIGate ~ queries · from a statement to an endpoint

Saved queries as endpoints

Each saved query is versioned, has typed parameters with ranges and lengths, and keeps a run history. OpenAPI and a Postman collection are generated, and OpenAPI Generator turns the spec into a typed client in about 50 languages. Group queries into collections and grant access to the whole collection at once.

curl
$ curl 'http://127.0.0.1:5000/q/example_top_films?top_n=2&category=Comedy&format=csv' \
    -H 'X-API-Key: demo-key'
title,category,rating,rentals,rank
Electric Signal,Comedy,PG,977,1
Crimson Garden,Comedy,PG-13,631,2

Versions, drafts, publishing

Every saved query has at most one published version: the one /q/<name> runs and the one OpenAPI, MCP and Postman describe. Newer versions are drafts, invisible to callers until published; the admin key can test one by number. Roll back to any earlier version. Older versions stay runnable with ?version=.

{{ v.label }}{{ v.status }}{{ v.note }}
{{ verNote }}
05 · Bound query parameters

Values bound, never spliced into the SQL

SELECT * FROM film WHERE film_id = :id answers GET /q/film_by_id?id=7. Values travel to the database separately from the SQL text, so they cannot be injected into it. A parameter can declare a type, default, required or optional, enum, numeric range, length and pattern; invalid input is rejected with a field-by-field 400 before it reaches the database.

QueryAPIGate ~ parameters · checked before the database
query_parameters.json
// query_parameters
{
  "rating":     {"type": "str", "enum": ["G","PG","R"], "default": "PG"},
  "max_length": {"type": "int", "min": 1, "max": 600},
  "title":      {"type": "str", "required": false, "pattern": "[A-Za-z ]+"},
  "customer":   {"type": "int", "from_claim": "sub"}
}
Try itEdit the values. The checks below run exactly as the rules above say, before anything reaches the database.
response
$ curl '/q/films?{{ tp.qs }}' -H 'X-API-Key: demo-key'
{{ tp.status }} {{ tp.body }}

Typed parameters, checked before the database

Each :name in the SQL is a bound parameter with a type, bounds, length, enum, pattern and default. A request that breaks a rule gets a 400 listing every problem and never reaches the database. from_claim takes a value from the caller's sign-in token instead of the request, so a user can only ever ask for their own rows.

06 · Multiple response formats

One saved query, eight ways to read it

JSON, NDJSON, XML, YAML, CSV, TSV, XLSX and Parquet are all available per request with ?format=, so the same saved query serves application clients, reporting and data pipelines. A Parquet export keeps column types and is typically a quarter of the CSV's size.

QueryAPIGate ~ formats · the same query, three ways
JSONobjects
NDJSONone row per line
XMLdocuments
YAMLconfig-friendly
CSVspreadsheets
TSVtab-separated
XLSXExcel
PARQUETtyped columns
formats
$ curl '/q/example_top_films?format=parquet' -H 'X-API-Key: demo-key' -o top_films.parquet
$ curl '/q/example_top_films?format=xlsx'    -H 'X-API-Key: demo-key' -o top_films.xlsx
$ curl '/q/example_top_films?format=ndjson'  -H 'X-API-Key: demo-key'
{"title":"Electric Signal","category":"Comedy","rating":"PG","rentals":977,"rank":1}
{"title":"Crimson Garden","category":"Comedy","rating":"PG-13","rentals":631,"rank":2}
07 · Pagination and streaming

Page what people read, stream what pipelines take

?page=2&page_size=50 pages results, with X-Has-More telling the caller whether more exist. For full exports, ?stream=true with CSV, TSV or NDJSON streams the entire result straight from the database cursor rather than buffering it, verified end to end with 1,000,000-row results on MySQL, PostgreSQL and ClickHouse.

The measured numbers →
QueryAPIGate ~ streaming · three ways to take a result
paging and streaming
$ curl '/q/example_rentals_since?since=2026-01-01&page=2&page_size=50' -i -H 'X-API-Key: demo-key'
HTTP/1.1 200 OK
X-Has-More: true
X-Row-Count: 50

$ curl '/q/example_rentals_since?since=2026-01-01&stream=true&format=csv' -H 'X-API-Key: demo-key' > rentals.csv
# 1,000,000 rows, straight from the database cursor, flat server memory throughout

$ queryapigate export example_rentals_since --out '/exports/{name}_{date}.csv'
# the same streaming path from cron, systemd or a Kubernetes CronJob; no server needs to be running

Paging

?page= and ?page_size= for typical results, with X-Has-More and X-Row-Count headers so a client knows when to stop.

Streaming

Constant memory however large the result, in CSV, TSV or NDJSON, with a row ceiling you can set server-wide (QUERYAPIGATE_STREAM_MAX_ROWS, unbounded by default).

Streaming exports →

Scheduled exports

queryapigate export writes a full result to a file through the same streaming path, with no server running. Atomic rename on completion, non-zero exit on failure, deliveries to S3, GCS, R2 or disk that resume where the last run stopped.

08 · Query caching

HTTP caching a saved query opts into

A saved query can set cache_ttl and get Cache-Control, ETag, conditional requests and 304 Not Modified, plus an X-Cache: HIT or MISS header on every response. Never applied to a query that writes.

QueryAPIGate ~ caching · miss, hit, not modified
three requests
$ curl -i '/q/example_top_films?category=Comedy' -H 'X-API-Key: demo-key'
HTTP/1.1 200 OK
X-Cache: MISS
Cache-Control: max-age=300
ETag: "7f3a…"

$ curl -i '/q/example_top_films?category=Comedy' -H 'X-API-Key: demo-key'
HTTP/1.1 200 OK
X-Cache: HIT

$ curl -i '/q/example_top_films?category=Comedy' -H 'X-API-Key: demo-key' -H 'If-None-Match: "7f3a…"'
HTTP/1.1 304 Not Modified

Response cache

A per-query cache_ttl sets Cache-Control and ETag, answers conditional requests with 304 and reports X-Cache: HIT or MISS; never on a query that writes. In-process by default; with Redis, one cache shared by every instance, surviving restarts.

HTTP done properly

ETag and 304 Not Modified on cached responses, If-Match for safe concurrent edits, X-Has-More and X-Row-Count for paging, and ?timeout per request.

09 · Rate limiting

Different callers, different budgets

A server-wide, IP-keyed limit and an independent, optional per-key limit can both be in effect at once; a request has to pass both. Over a limit the answer is a 429 with the budget left in its headers.

QueryAPIGate ~ rate limiting · three callers, three budgets
Internal dashboardserver-wide limit onlyA generous IP-keyed budget set once with QUERYAPIGATE_RATE_LIMIT, no per-key limit.
Partner keyrate_limit: 200/hourIts own budget on top of the server-wide one; a request has to pass both.
Trial keyrate_limit: 20/hour · expires_atA small budget and an expiry date for an evaluation that ends on its own.

Server-wide

QUERYAPIGATE_RATE_LIMIT keyed by caller IP, enforced on every front door. With Redis, one budget across every instance; if Redis blinks, limits stay enforced per process.

Per key

rate_limit on the key, plus allowed_ips to pin it to addresses or CIDR ranges and expires_at to end it on a date.

Fair shares and quotas

max_concurrent, query_timeout and max_rows cap what one key can hold at once; quota counts calls and rows per day or month, with X-Quota-Remaining-* headers and an alert before it runs out.

10 · ObservabilityPathGo to production →

Every run recorded, every change audited

Structured JSON logs tagged with a request ID, per-query execution timing, slow-query warnings over a threshold you set, and Prometheus metrics at /metrics covering request and query counts and latencies, connection-pool occupancy and rate-limit rejections. Run history is per query and per key; the audit log records who changed what.

QueryAPIGate ~ observability · run history and alerts

Run history

When, which connection, which key, through which front door, how long, how many rows. Ad-hoc and MCP runs too.

Audit log

Timestamp, actor, action, target and the fields that changed, each entry chained to the one before. Secrets appear as "changed", never their value.

Run store Exp

Run history, the event log and usage in ClickHouse, for a data plane's rates: every run recorded, in about 13 bytes, and usage reports exact.

The run store →

Request IDs

Every response carries X-Request-Id; every log line written for it carries the same.

Alerts

GET /api/v1/alerts
critical
open_server · connection_failing · sso_unreachable · audit_chain_broken · secret_provider_failingNo key configured; a connection's last runs all failed; nobody can sign in; the audit log was edited; Vault or AWS stopped answering.
warning
key_expiring · query_slow · query_errors · writes_awaiting_approval · quota_nearly_used · agent_unusual_volume · export_failing · audit_delivery_behindA key expires within 7 days; a query's median run is over threshold; agents' writes wait for a person; a quota is 80% used; an agent reads ten times its usual volume; an export or the SIEM stream falls behind.
info
key_unusedAn active key unused for 90 days.

Also watched: break_glass_used, key_expired, query_timeouts, key_rate_limited, client_rate_limited, history_failed, history_dropped, instances_not_shared, instances_versions_differ, rate_limits_not_shared, encryption_key_rotation_incomplete. 25 kinds in all.

Shown on the Console's Alerts screen, the bell in its header, and Home's System health. For paging, scrape /metrics with Prometheus.

Observe

Structured JSON logs with request IDs, slow-query warnings over a threshold you set, per-key metrics, a Prometheus /metrics endpoint behind a token, a bundled Grafana dashboard, and OpenTelemetry spans Exp from the request to the database call, never holding a value or a row.

Store

A SQLite file by default, or PostgreSQL for several instances. Versioned schema with upgrades tested in CI, backup and restore, passwords encrypted at rest.

Scale out

Redis, optional: one cache, one rate-limit budget and one event stream across every instance. If Redis blinks, limits stay enforced per process.

Deploy

pip, the image on ghcr (with an -h2 variant including Java), a Helm chart for one pod or several, and a multi-instance Compose setup behind Caddy. Non-root, read-only root filesystem.

Secrets

Passwords encrypted at rest under a key you can rotate, or no password at all: ${file:…}, ${vault:…} and ${aws-sm:…} references, short-lived credentials from Vault or RDS IAM. AWS paths experimental.

Configuration as code Stable

config export, config diff and config apply: the whole setup as one YAML file with no secret in it, reviewed in a pull request and applied all or nothing, by CI or at startup.

Locked, if you want it. With QUERYAPIGATE_CONFIG_LOCKED, what the file manages can't be changed in the Console or the API, only through the file.

Drift, shown. Without the lock, a change made by hand on prod shows in config diff, and the next apply undoes it.

GitOps. On Kubernetes, the Helm chart's configAsCode applies the file as the first new pod starts: Argo CD or Flux syncing the chart is the deploy.

Guide 55 →
11 · OpenAPI

Documented, predictable and scriptable

/openapi.json is a complete, valid OpenAPI 3.0 document, checked in CI against the official validator, with every saved query listed as its own typed endpoint; /docs serves it through Swagger UI. A key's endpoint list is filtered to what that key can actually reach, so a query-scoped external key sees only its approved queries. Every error has the same shape, and every response carries a request ID.

QueryAPIGate ~ openapi · one error shape, everywhere
error.json403 forbidden
403 {
  "error": "key may not run this query",
  "code": "forbidden",
  "request_id": "req_01J9…"
}

Interactive docs and a catalogue

/docs lists every saved query as its own endpoint with typed parameters; /catalog tells each caller what it can call and on what terms: cached, writable, its rate limit. /openapi.json is checked in CI against the official validator, and a key sees only the endpoints it can reach.

Typed clients

OpenAPI 3.0, validated in CI, turned into a client in about 50 languages by OpenAPI Generator; a Postman collection per collection.

See it on Home →

Stable error codes

Every error has the same shape: error, a code and a request_id. Codes are part of the compatibility promise: an existing one is never renamed or repurposed.

Everything scriptable

The Management API, /api/v1, does everything the Console does: connections, queries, keys, roles, administrators, audit. Or keep the whole setup in one YAML file.

Guide 55 →

Collections as a unit

Group queries, grant them together, rename, export and import a collection between servers, and hand out its Postman collection.

Guide 7 →

Browser front ends

QUERYAPIGATE_CORS_ORIGINS for the sites you control, with your users' own tokens rather than a shared key in the page.

Guide 38 →

Try it without setup

queryapigate examples load: four worked APIs on a generated database, with collections, roles and a scoped key for each.

Guide 5 →

Live events

Server-sent events as queries run, per key if you want. queryapigate events reads every instance's runs from the shared store, woken by a PostgreSQL notification.

12 · Administration UIPathShip data out →

The whole workflow, in one built-in Console

A built-in UI at /console covers the whole workflow: connection management, a SQL editor with syntax highlighting and schema browsing, query execution and preview, EXPLAIN, saved-query and version management, API keys, the audit log, per-query run history, a collapsible JSON tree for non-tabular results, a quick bar chart of any numeric result, and copy as curl or TSV for any result.

QueryAPIGate ~ console · screen by screen
Connect databaseWrite SQLTest querySave queryConfigure accessExpose API

The Console

Manage connections, saved queries, API keys and roles. Run ad-hoc SQL with a schema browser. Review the audit log and alerts, approve agents' writes, manage administrators, exports and quotas, and see who can reach what on the access map.

Served from /console, nothing extra to install. A collapsible sidebar (Ctrl+B) and a read-only Settings screen that shows every variable's effective value (secrets as set or not, never shown) with copy as .env; theme, density and default format kept per browser. Everything it does is also available on the Management API.

QueryAPIGate Console: Home QueryAPIGate Console: API Designer QueryAPIGate Console: API Repository QueryAPIGate Console: API keys QueryAPIGate Console: Access map QueryAPIGate Console: Administrators QueryAPIGate Console: Audit log

{{ caption.title }} {{ caption.text }}

{{ counter }}

Who can reach what

Which keys can call this query, and what this key can call, from the grants as they add up. Moving a query between collections is audited with the keys that gained or lost access.

Guide 21 →

What's being used

30-day activity on every access screen (keys, connections, cached queries, MCP tools, roles) and a details screen per key: what's busy, and what's granted but never touched.

Guide 62 →
13 · Testing and qualityPathGo to production →

The evidence an auditor or a security questionnaire asks for

Unit tests; integration tests against real PostgreSQL, MySQL, ClickHouse, H2 and MongoDB servers; property tests and fuzzing of the SQL guard; CodeQL, mypy and ruff; CI on every push across Python 3.11 to 3.14. Audit, cryptography and release signing are on the Enterprise page, data handling in the Trust center.

Contributing →
QueryAPIGate ~ testing · what a release ships with
Audit · FIPS · cosign

Audit, cryptography and signed releases

The signed audit log and its copy in your SIEM, FIPS-approved cryptography, and releases you can verify before they run.

Enterprise →
Data map

Where data goes

Every place data is kept or sent, for how long and who reads it. Query strings kept out of the access log. history forget erases one person's runs on request.

Read it →
Headers · SRI

Hardened at the edge

Security headers on every response from all three servers, a pinned Swagger UI with Subresource Integrity, no version banner.

Security →

Tested against real servers

Integration tests run in CI against PostgreSQL, MySQL, ClickHouse, H2 and MongoDB service containers, and the job fails if one of them skips. DuckDB's run unconditionally, since it is embedded.

Guards fuzzed and property-tested

The SQL guard is property-tested with Hypothesis and fuzzed with Atheris under ClusterFuzzLite; CodeQL scans every push.

The fuzzing setup →

Checked on every push

mypy and ruff, on Python 3.11 to 3.14, at the oldest dependency versions allowed, on a PostgreSQL store and under FIPS.

Contributing →
14 · What happens to every request

Eight stages, the same for REST, MCP, events and exports

Every call passes the same eight stages, whichever front door it came through. Home follows one request through them, with the refusals a bad key or parameter would get at each step.

Follow a request on Home →

Identify the caller

an API key, an admin token, a signed-in user's JWT or an OAuth token.

See this stage →

Apply its limits

rate limit, IP allow-list, expiry.

See this stage →

Check the grant

this query, this connection, read or write.

See this stage →

Validate parameters

types, ranges, lengths; values are bound, never spliced into the SQL.

See this stage →

Guard the SQL

one statement, read-only unless allowed, only the tables a key may see.

See this stage →

Run it

on a pooled connection, within the caller's time and row limits.

See this stage →

Mask and format

masking traced to the source columns, then the format asked for.

See this stage →

Record it

run history, metrics and live events; the audit log for changes.

See this stage →

Python decides, Rust serves Exp

An optional Rust data plane on the public port serves saved-query reads from the core's own plans, and forwards everything else. Every decision stays in the core.

The data plane →