Performance¶
For 0.43's figures, read Performance, v4 (PostgreSQL) and Performance on MySQL, v2. For the method, Performance, v3. For the Rust data plane in front of
/q/(ADR 0028), read Performance, v5.For PostgreSQL, read Performance, v3 instead. It's a more careful run: pinned cores, 5 interleaved rounds per comparison with their ranges, open-loop load, a simulated network, a measured round-trip count, and a profile. It also corrects two figures on this page. A call's statements on the store went from 7 to 3, not "about 5.5 to about 2.6". And the PostgreSQL throughput here is about half what that machine gives in a steady state.
The short answer: QueryAPIGate adds a millisecond or two to a request, and almost none of that is the governance. In front of PostgreSQL, one container in its default configuration serves about 300-400 requests a second, reads or writes, with a key's masking and table allow-list on or off. Throughput grows with the number of containers. The database is rarely the limit: in these tests PostgreSQL used about a core or less per container.
This page is what we measured, where the time goes, and how to measure it on your own hardware. Every number comes
from one laptop, so read them as a guide to proportions, not a promise. The same laptop measured about half these
numbers earlier on the same day, even on /health, which does no work. Compare numbers within a table, not across
tables or with your own machine.
How it was measured¶
- The machine: a 20-thread Intel i7-12700H laptop with 32 GB of memory. The load generator, QueryAPIGate and the database all ran on it, so there's no network hop, and the load generator takes CPU from the server.
- The server: QueryAPIGate as the Docker image runs it: gunicorn, one worker with eight threads
(
--worker-class gthread), the access log on. For Scaling out, 2, 4 and 8 workers. - The load: oha, a 3-second warm-up, then 10 seconds per run. For PostgreSQL, each request asks for a random row, so nothing is answered from a cache the test made warm.
- Two setups:
- the bundled example APIs on SQLite, to show the gateway's own cost on a database that answers in microseconds;
- an OLTP database on PostgreSQL 16 - pgbench's 10-million-row bank schema, with QueryAPIGate's own store in the same PostgreSQL, as a production deployment would have it.
Measured 2026-10-23 on main after 0.41.0, with the changes this page describes.
The example APIs on SQLite¶
- Two queries:
- a primary-key lookup (
SELECT film_id, title FROM film WHERE film_id = :film_id), so the gateway's own cost shows; example_film_lookup, a join and aggregate over the rentals table, as a realistic query.
- a primary-key lookup (
- Two kinds of key:
- plain - a scoped key with no masking and no table allow-list;
- governed - the same key with
allowed_tablesand a masking rule that hashestitleon every row.
Requests a second, and latency in milliseconds:
| Call | Concurrency | req/s | p50 | p99 |
|---|---|---|---|---|
/health - the floor |
1 | 1,583 | 0.6 | 1.3 |
| Primary-key lookup, plain key | 1 | 839 | 1.2 | 1.9 |
| Primary-key lookup, governed key | 1 | 778 | 1.2 | 2.4 |
| Join query, plain key | 1 | 526 | 1.9 | 2.9 |
| Join query, governed key | 1 | 510 | 1.9 | 3.1 |
| Join query, governed key | 8 | 583 | 12.6 | 26.7 |
| Join query, governed key | 32 | 505 | 61.8 | 92.5 |
A governed call costs about the same as a plain one. Masking and the table check are worked out once per statement and reused, so on a repeated call they add almost nothing.
Throughput stays flat as concurrency rises; latency is what grows. One worker process runs Python code on one core at a time, so extra concurrent callers wait their turn: 32 callers sharing about 500 requests a second each wait about 60 ms. That's why you scale by adding processes rather than threads (see Scaling).
An OLTP database on PostgreSQL¶
The database¶
pgbench's TPC-B-like schema at scale 100 (pgbench -i -s 100): 1.5 GB, in a postgres:16 container started with
-c shared_buffers=2GB -c max_connections=300 - the whole database fits in memory, so this measures the gateway,
not the disk.
| Table | Rows | Columns | Key |
|---|---|---|---|
pgbench_accounts |
10,000,000 | aid integer, bid integer, abalance integer, filler character(84) |
primary key aid |
pgbench_branches |
100 | bid integer, bbalance integer, filler character(88) |
primary key bid |
pgbench_tellers |
1,000 | tid integer, bid integer, tbalance integer, filler character(84) |
primary key tid |
pgbench_history |
0 at the start | tid, bid, aid, delta integer, mtime timestamp, filler character(22) |
none |
QueryAPIGate's own store - keys, saved queries, connections, run history - was a second database, qagstore, in the
same PostgreSQL (QUERYAPIGATE_DATABASE_URL).
The calls¶
Three saved queries, run through /q/<name> with a random id on every request:
| Saved query | SQL | Called as |
|---|---|---|
account - a lookup |
SELECT aid, bid, abalance FROM pgbench_accounts WHERE aid = :aid |
/q/account?aid= 1 to 9,999,999 |
account_page - a 100-row page with a join |
SELECT a.aid, a.abalance, b.bid, b.bbalance FROM pgbench_accounts a JOIN pgbench_branches b ON b.bid = a.bid WHERE a.aid BETWEEN :start AND :start + 99 ORDER BY a.aid |
/q/account_page?start= 1 to 999,999&page_size=100 |
deposit - a write |
UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid |
/q/deposit?aid= 1 to 9,999,999&delta= 1 to 9 |
And three keys:
- plain - granted
accountandaccount_page, nothing else; - governed - the same, plus
allowed_tables(pgbench_accounts,pgbench_branches) and a masking rule that hashes*.abalance; - writer - granted
deposit, withallow_writesandallowed_write_ops: ["update"], on a server started withQUERYAPIGATE_ALLOW_WRITES=1.
Every request in every run below returned 200, and the writes landed.
The database on its own¶
pgbench against the same container, for scale:
| Workload | Clients | Transactions a second | Average latency |
|---|---|---|---|
Primary-key read (pgbench -S) |
1 | 11,821 | 0.085 ms |
Primary-key read (pgbench -S) |
32 | 221,481 | 0.144 ms |
| TPC-B transaction - three updates, a select and an insert | 32 | 20,869 | 1.5 ms |
A lookup costs PostgreSQL under a tenth of a millisecond. Whatever a call through the gateway takes beyond that is the gateway.
Through the gateway: one container¶
One worker, eight threads, 32 concurrent callers; requests a second. Before is the request path before the changes
to the store reads and the pool below. Now is this version's, at its defaults. Now, with the metadata cache adds QUERYAPIGATE_METADATA_CACHE_TTL=5 (see
The metadata cache).
| Call | Before | Now | Now, with the metadata cache |
|---|---|---|---|
| Lookup, plain key | 326 | 368 | 430 |
| Lookup, governed key | 343 | 338 | 392 |
| 100-row page | 306 | 318 | 346 |
| Write | 326 | 368 | 420 |
The lookup row is the median of three rounds, run interleaved; the others are one run each. Runs varied by up to about 10% between rounds, so a smaller difference than that, such as the governed lookup's, isn't one.
Latency for one caller at a time, the lookup with a plain key, median of three rounds:
| req/s | p50 | |
|---|---|---|
| Before | 414 | 1.9 ms |
| Now | 440 | 1.8 ms |
| Now, with the metadata cache | 692 | 1.2 ms |
Scaling out in front of PostgreSQL¶
Requests a second with 64 concurrent callers, by the number of gunicorn workers - each one a process like a container's:
| Workers | Before: lookups / writes | Now: lookups / writes | Now, with the metadata cache: lookups / writes |
|---|---|---|---|
| 1 | 339 / 380 | 419 / 410 | 473 / 464 |
| 2 | 1,094 / 921 | 933 / 928 | 1,144 / 1,157 |
| 4 | 1,336 / 1,308 | 1,444 / 1,447 | 1,876 / 1,830 |
| 8 | 1,926 / 1,763 | 2,093 / 2,078 | 2,732 / 2,650 |
- Processes add throughput. Eight of them served five to six times what one did, on the same database.
- One process with 64 callers does worse than its share. The callers queue for its eight threads, which is why the step from one worker to two is more than double. Don't size capacity from one overloaded process.
- The metadata cache matters more as you scale out. With 8 workers, it's 30% more requests a second, and the store's PostgreSQL has far less to do.
- These are workers on one 20-thread laptop, sharing it with the database and the load generator. Containers on their own hosts would scale more cleanly.
Where the time goes¶
On SQLite, each part of a call, measured on its own:
| Part of a call | Primary-key lookup | Join query |
|---|---|---|
| The database query itself | 0.009 ms | 0.26 ms |
| The read-only SQL check | 0.01 ms | 0.03 ms |
The table check (allowed_tables), first call for a statement |
0.3 ms | 1.5 ms |
| Masking's trace of the result columns, first call for a statement | 6.9 ms | 28.9 ms |
The first call for a statement pays for the table check and the masking trace; after that, both are reused. The table check depends only on the query text, so it can't go stale. The masking trace depends on the connection's schema too, so it is kept only as long as QueryAPIGate keeps that schema - at most 60 seconds - and straight away when the connection is changed. From 0.43 a busy connection's schema is read again in the background after 45 seconds, while calls go on using the cached copy; when it hasn't changed, the traces are kept. So no call waits for the read or the trace again - before, one call a minute took about 75 ms longer (Performance, v3). A connection with no masked call for a whole minute is read again in its next call, as before.
The rest is the gateway itself, the same for every query: authentication, limits, the record of the run, the HTTP response. Against a database that takes 50 ms to answer, it hardly matters; against one that answers in 0.01 ms, it's nearly the whole call. Serialization is small at typical page sizes - about 0.1 ms here.
Part of that fixed cost is QueryAPIGate's own store. Every call reads the key its secret belongs to, the saved query
and the connection, and adds a run to the history. Transactions on the store, per call, counted in PostgreSQL's
pg_stat_database over 200 lookups:
| Store transactions per call | |
|---|---|
| Before | about 5.5 - the saved query was read three times |
| Now | about 2.6 - one read each for the key, the query and the connection; history in batches |
| Now, with the metadata cache | about 0 - only the batched history writes, about one a second |
The data database saw about one transaction per call throughout: the query itself.
What governance costs¶
Column masking has to know which source column each result column comes from, so it traces the query through its aliases, joins, subqueries and unions - a few milliseconds of SQL analysis. The table check parses the query to find the tables it reads. Before the caches, both ran on every call, and a governed key was 4-8 times slower than a plain one.
Caching doesn't change what is masked or what is refused. A query that can't be traced or checked is refused every time, the same as before. Both caches have a fixed size, so a key sending many different ad-hoc queries can't make them grow without bound.
The metadata cache¶
QUERYAPIGATE_METADATA_CACHE_TTL (seconds, off by default) keeps the rows a call reads from the store - the API key,
the saved query, the connection - in memory for that long, instead of reading them again on every call. In the
tests above, it took the store out of a call's path: 10-20% more throughput for a container, and a third off the
latency of a single call (1.8 ms to 1.2 ms).
What it caches is the rows, never a decision: whether a key is active, expired, used from an allowed address or still inside a rotation's grace period is checked on every call, against the cached row. What it can delay is a change:
- A change made through the same server - the Console, the Management API - is seen at once: every change to the store clears this server's cache.
- A change made anywhere else - another instance, another gunicorn worker, the
queryapigateCLI, an edit straight to the database - is seen up to one TTL later. A key revoked on one instance keeps working on the others for that long.
So it is a trade: a few seconds of delay for a revocation that reaches other instances, for fewer reads on the store. Leave it off if a revoked key must stop working everywhere at once; with one instance, a TTL of a few seconds changes nothing anyone would notice.
Where it is set:
| Setup | QUERYAPIGATE_METADATA_CACHE_TTL |
Why |
|---|---|---|
| The server and the Docker image | 0, off |
The image runs anywhere, often several instances at a time |
The one-command Compose demo (docker-compose.yml) |
5 |
One instance: only a change made with the CLI waits, up to 5 s |
The scale-out Compose setup (deploy/scale-out/) |
off | Three instances: a revocation would take up to a TTL to reach the other two |
| The Helm chart | off; config.QUERYAPIGATE_METADATA_CACHE_TTL |
Set it with one replica, or when the delay is acceptable |
Change it for any setup with the variable; the Console's Settings screen shows the value in use.
Scaling¶
- More containers, not more threads. Each container is one Python process. Run several behind a load balancer on a shared PostgreSQL store and Redis, and throughput grows with the number of containers - see Scaling out and Run several instances behind a load balancer.
- Why one worker per container.
/metricsis counted per process (and, without Redis, so are rate limits), so with several workers in one container a scraper would read one worker's numbers at random. One worker per container keeps every count complete. - The eight threads are for waiting. They let one container keep serving while some calls wait on a slow
database or a long-lived
/eventsconnection. They don't add CPU: in an earlier, slower run on the same laptop, one worker served 119, 169 and 164 requests a second with 1, 2 and 8 threads. - The database can take it. In that earlier run, at 8 processes and 1,700 requests a second, PostgreSQL used about four cores - mostly for QueryAPIGate's own store, not the queries. The metadata cache takes most of that away.
Making it faster¶
- Cache what you can. A saved query with a
cache_ttlis answered without the database for repeat calls. - Turn on the metadata cache if a few seconds' delay for a change made on another instance is acceptable.
- Tune the connection pool for the number of callers - Diagnose and tune the connection pool. The default of 8 idle connections matches the Docker image's 8 threads. Below that, a busy server closes connections only to open new ones for the next calls.
- Stream large results with
?stream=trueinstead of paging through them - see Streaming exports. - Look at the database first. On anything bigger than a primary-key lookup, the query plan and indexes usually matter more than anything in the gateway.
What wasn't measured¶
MySQL and ClickHouse as the data source; a database across a network, with the round trip that comes with it; large result sets; the MCP server; and several containers behind a load balancer - the scaling numbers are several workers on one machine. Measure on your own hardware before you plan capacity from these numbers.
Measure it yourself¶
On the example APIs¶
Start a server the way the Docker image does, on the example APIs:
pip install "queryapigate[server,flow]" # flow: the SQL analysis masking uses
mkdir -p /tmp/qag-bench
QUERYAPIGATE_HOME=/tmp/qag-bench QUERYAPIGATE_API_KEY=bench-admin QUERYAPIGATE_LOAD_EXAMPLES=1 \
gunicorn --bind 127.0.0.1:5090 --workers 1 --worker-class gthread --threads 8 \
--access-logfile /tmp/qag-bench/access.log -c python:queryapigate.gunicorn_conf 'queryapigate.app:create_app()'
Create a governed key, then put load on a query with it:
curl -s -X POST http://127.0.0.1:5090/api/v1/api-keys -H 'X-API-Key: bench-admin' -H 'Content-Type: application/json' \
-d '{"name": "bench-governed", "queries": ["example_film_lookup"], "connections": [],
"allowed_tables": ["film", "rental"], "mask": [{"column": "*.title", "action": "hash"}]}'
# copy the "secret" from the response
docker run --rm --network host ghcr.io/hatoo/oha -z 10s -c 8 -H 'X-API-Key: <the secret>' \
'http://127.0.0.1:5090/q/example_film_lookup?film_id=7'
Run a few seconds first as a warm-up - the first calls for each statement pay for the table check and the masking
trace - then measure for at least 10 seconds: once with -c 1 for latency on an idle server, then at higher
concurrency for throughput. Point it at your own queries and database for numbers you can plan from.
On PostgreSQL¶
A PostgreSQL with pgbench's schema, and a second database for QueryAPIGate's store:
docker run -d --name qag-bench-pg -e POSTGRES_PASSWORD=pw -p 55433:5432 postgres:16 \
-c shared_buffers=2GB -c max_connections=300
docker exec qag-bench-pg psql -U postgres -c 'CREATE DATABASE bench' -c 'CREATE DATABASE qagstore'
docker exec qag-bench-pg pgbench -U postgres -i -s 100 bench # 10 million accounts, about 1.5 GB
docker exec qag-bench-pg pgbench -U postgres -S -c 32 -j 8 -T 10 bench # the database on its own
QueryAPIGate on that store - psycopg2 is in the postgres extra:
pip install "queryapigate[server,postgres]"
mkdir -p /tmp/qag-oltp
QUERYAPIGATE_HOME=/tmp/qag-oltp QUERYAPIGATE_API_KEY=bench-admin \
QUERYAPIGATE_DATABASE_URL=postgresql://postgres:pw@127.0.0.1:55433/qagstore \
gunicorn --bind 127.0.0.1:5091 --workers 1 --worker-class gthread --threads 8 \
-c python:queryapigate.gunicorn_conf 'queryapigate.app:create_app()'
A connection, the lookup as a saved query, and a key for it:
A='X-API-Key: bench-admin'; J='Content-Type: application/json'; U=http://127.0.0.1:5091
curl -s -X POST $U/api/v1/connections -H "$A" -H "$J" -d '{"name": "oltp", "db": "postgres", "host": "127.0.0.1",
"port": 55433, "database": "bench", "user": "postgres", "password": "pw", "active": true}'
curl -s -X POST $U/api/v1/queries -H "$A" -H "$J" -d '{"name": "account", "connection_name": "oltp",
"sql": "SELECT aid, bid, abalance FROM pgbench_accounts WHERE aid = :aid",
"parameters": {"aid": {"type": "int", "required": true}}, "description": "One account", "publish": true}'
curl -s -X POST $U/api/v1/api-keys -H "$A" -H "$J" -d '{"name": "bench", "queries": ["account"], "connections": []}'
# copy the "secret" from the response
Then the load, with a random account on every request:
docker run --rm --network host ghcr.io/hatoo/oha -z 10s -c 32 -H 'X-API-Key: <the secret>' \
--rand-regex-url 'http://127.0.0.1:5091/q/account\?aid=[1-9][0-9]{0,6}'
Restart gunicorn with QUERYAPIGATE_METADATA_CACHE_TTL=5 to see the metadata cache, or with --workers 4 to see a
second process's worth. When you're done, docker rm -fv qag-bench-pg removes the container and its data.