Read-only SQL for coding agents.
One command, one SELECT, against Oracle, MySQL, Postgres or SQL Server. The database itself refuses to write. The output costs as few tokens as it can.
nix
nix profile install github:gayanhewa/dbq
uv / pipx
uv tool install git+https://github.com/gayanhewa/dbq
Why
An agent that can read production-shaped data writes better code than one that guesses
from the schema. The trouble is handing it a database connection. dbq gives it a
narrow door: one SELECT at a time, credentials it never sees, and a server-side guarantee
that nothing changes.
Every driver is pure Python. oracledb runs in thin mode and speaks the wire
protocol directly; pymysql, pg8000 and python-tds need
nothing installed beyond themselves. It runs wherever Python 3.11 runs.
Read-only is enforced by the database
On Oracle, MySQL and Postgres every query runs inside a read-only transaction:
SET TRANSACTION READ ONLY on Oracle and Postgres, START TRANSACTION READ ONLY
on MySQL. A write fails server-side even when the account has permission.
$ mysql ... -e "START TRANSACTION READ ONLY; DELETE FROM widget WHERE id=1;"
ERROR 1792 (25006): Cannot execute statement in a READ ONLY transaction.
$ psql ... -c "BEGIN; SET TRANSACTION READ ONLY; DELETE FROM widget WHERE id=1;"
ERROR: cannot execute DELETE in a read-only transaction
That is the guarantee on those three. dbq also checks that the statement starts
with SELECT or WITH, but only so the error message is clearer than the
driver's. The string check is not what makes this safe. SQL has too many ways to hide a write.
SQL Server is weaker. It has no read-only transaction mode. There, dbq
opens an explicit transaction and always rolls it back, so the SELECT-only check becomes the
first line of defence and the rollback the second. Consumed identity values and anything a
stored procedure commits on its own survive a rollback. Point it at a read-only login where you can.
Config
Profiles live in ~/.config/dbq/config.toml. A profile either carries the connection
string inline or names a .env you already have, so the credential is never copied
somewhere new to go stale. The agent passes a profile name and never sees the value.
default = "work"
[profiles.work]
driver = "oracle"
env_file = "~/code/app/.env" # read the DSN from an existing .env
env_var = "ORACLE_CONNECTION"
schema = "APP_SCHEMA" # optional, substituted for {{schema}}
[profiles.analytics]
driver = "postgres"
dsn = "postgres://reader:pw@127.0.0.1:5432/analytics"
[profiles.legacy]
driver = "sqlserver"
env_file = "~/code/legacy/.env"
env_var = "SQL_CONNECTION" # Server=host,1433;Database=app;User Id=ro;Password=pw
Then:
dbq --list-profiles
dbq --tables
dbq --describe ORDERS
dbq --profile work --sql "SELECT * FROM {{schema}}.TENANT_SETTING"
dbq --profile analytics --file query.sql --json
# no config needed
dbq --driver mysql --dsn 'mysql://root:pw@127.0.0.1:3306/app' --sql 'SELECT 1'
Connection strings parse in whatever shape the app already uses: URLs
(mysql://, postgres://, mssql://), .NET key=value strings
with Server=host,1433 or Data Source=host:1521/svc, and Oracle
EZ-connect (scott/tiger@host:1521/ORCL).
Output built for a reader that pays per token
The default format is toon: a header naming the columns once, then bare rows.
It costs far less than a table or JSON when an agent reads it. --json switches when
a program is on the other end.
profile: work
driver: oracle
host: db.example.com
schema: APP_SCHEMA
count: 3
rows[3]{ID,NAME,NOTE}:
1,alpha,null
2,beta,"has,comma"
3,gamma,x
Cells are quoted only when they contain a comma, quote or newline. null is distinct
from the empty string. An empty result prints (no rows), so "no matches" never looks
like "the query failed". --max-rows defaults to 100 and says so in the header when it
truncates.
Examples
The workflow the skill teaches: look before you query. --tables and
--describe replace guessing at column names, which is the most common cause of a
wrong query.
$ dbq --profile work --describe TENANT_SETTING
profile: work
driver: oracle
host: db.example.com
schema: APP_SCHEMA
count: 4
rows[4]{COLUMN_NAME,DATA_TYPE,NULLABLE}:
ID,NUMBER,N
TENANT_ID,NUMBER,N
KEY,VARCHAR2,N
VALUE,VARCHAR2,Y
$ dbq --profile work --sql "SELECT key, value FROM {{schema}}.TENANT_SETTING WHERE tenant_id = 42"
profile: work
driver: oracle
host: db.example.com
schema: APP_SCHEMA
count: 2
rows[2]{KEY,VALUE}:
currency,AUD
timezone,Australia/Sydney
An empty result is an answer, not an error. The exit code is 0 and the header says so.
$ dbq --profile analytics --sql "SELECT id FROM orders WHERE status = 'refunded' AND created_at > now() - interval '1 day'"
profile: analytics
driver: postgres
host: 127.0.0.1
count: 0
rows[0]{id}:
(no rows)
Multi-line SQL goes in a file. Shell quoting is how queries get mangled.
$ cat slow-tenants.sql
WITH per_tenant AS (
SELECT tenant_id, count(*) AS n, avg(duration_ms) AS avg_ms
FROM request_log
WHERE created_at > now() - interval '1 hour'
GROUP BY tenant_id
)
SELECT tenant_id, n, round(avg_ms) AS avg_ms
FROM per_tenant
WHERE avg_ms > 500
ORDER BY avg_ms DESC
$ dbq --profile analytics --file slow-tenants.sql --max-rows 3
profile: analytics
driver: postgres
host: 127.0.0.1
count: 3
truncated: true, showing first 3
rows[3]{tenant_id,n,avg_ms}:
42,1180,2210
7,312,911
91,88,640
--json when a program is on the other end. The same fields, as an object.
$ dbq --profile legacy --json --sql "SELECT TOP 2 id, name FROM dbo.customer ORDER BY id"
{
"profile": "legacy",
"driver": "sqlserver",
"host": "legacy-db.internal",
"count": 2,
"columns": ["id", "name"],
"rows": [
{ "id": 1, "name": "Acme" },
{ "id": 2, "name": "Globex" }
]
}
$ dbq --profile legacy --json --sql "..." | jq -r '.rows[].name'
Writes stop at the door, with a message an agent can act on.
$ dbq --profile local --sql "DELETE FROM widget WHERE id = 1"
error: only SELECT and WITH are allowed, got DELETE
$ echo $?
2
Row limiting differs by engine, and the driver line in every header tells the
reader which one it is talking to: FETCH FIRST 10 ROWS ONLY on Oracle,
LIMIT 10 on MySQL and Postgres, TOP 10 on SQL Server.
Drivers
| Database | Library | Read-only guard | Default port |
|---|---|---|---|
| Oracle | oracledb (thin) | SET TRANSACTION READ ONLY | 1521 |
| MySQL | pymysql | START TRANSACTION READ ONLY | 3306 |
| Postgres | pg8000 | SET TRANSACTION READ ONLY | 5432 |
| SQL Server | python-tds | SELECT check, then rollback | 1433 |
Every push runs the integration suite against real containers of all four, so the guard is tested against a live server, not mocked.
Give it to your agent
The repo ships a skill, a folder with a SKILL.md that teaches the discipline the tool
assumes: use profiles instead of pasting credentials, run --describe before writing
SQL against an unseen table, and treat count: 0 as an answer rather than a failure.
Install the binary first, then the skill.
Any agent
Claude Code, Codex, Copilot, Cursor, opencode, pi. Anything that reads SKILL.md folders.
npx skills add gayanhewa/dbq
Claude Code plugin
The repo is its own marketplace.
/plugin marketplace add gayanhewa/dbq
/plugin install dbq@dbq
By hand
Symlink the folder into wherever your agent looks. Nix users can link it out of the store.
ln -s "$(pwd)/skills/dbq" ~/.claude/skills/dbq