querydrift
v0.2.0
Published
Fail your CI when a PR introduces N+1 query regressions. Node.js + PostgreSQL.
Maintainers
Readme
querydrift
Fail your CI when a pull request introduces an N+1 query regression.
For Node.js / TypeScript projects on PostgreSQL — Prisma or raw pg, Vitest or Jest.
Quick start · How it works · AI agents · CLI · FAQ

Real run against Postgres — see demo/. Rendered with vhs.
Why
N+1 queries are the most common silent performance regression in ORM-backed apps. A query that runs once per request quietly becomes one query per row — fine against 50 rows in dev, a production incident against 50,000. They pass code review because the diff looks innocent, and they pass tests because the test database is small.
querydrift turns that into a failing CI check. You capture a baseline of every query pattern your tests fire, commit it, and every PR is diffed against it. When a test starts firing the same query 3× more than baseline, CI fails and names the test, the pattern, and the count.
- Root-cause attribution. Failures point at the exact
file:linein your code that fired the repeating query — the call inside the loop — not just the SQL. Best-effort, from async stack traces; no instrumentation of your code. - No server. The baseline is a JSON file in your repo. CI runs fully offline.
- No account, no API key, no telemetry. Nothing leaves your machine, ever.
- No per-test annotations. It captures all queries automatically and attributes them to the test that fired them — you don't decorate your tests.
- Single install.
npm i -D querydrift. No native binaries, no Docker, no daemon.
Quick start
1. Install
npm install --save-dev querydrift2. Activate the interceptor in your test setup file
// vitest.setup.ts (or jest.setup.ts)
import { setupPg, setupPrisma } from 'querydrift'
setupPg() // raw pg — must run before any test imports pgFor Prisma, pass your client (constructed with query-event logging):
import { setupPrisma } from 'querydrift'
import { prisma } from './src/db' // new PrismaClient({ log: [{ emit: 'event', level: 'query' }] })
setupPrisma(prisma)Prisma 7: works unchanged where
$on('query')is available. If your setup drops$on(client extensions, some driver-adapter configs), usesetupPg()instead — Prisma 7's@prisma/adapter-pgruns onpg, so the driver-level patch captures everything, with betterfile:lineattribution too. Don't enable both, or queries are counted twice.
Vitest: set
globals: truein your config. querydrift reads the current test name from the globalexpectto attribute queries; without it, queries can't be matched to a test. (Jest exposesexpectglobally by default.)
3. Capture the baseline on main and commit it
npx querydrift baseline
git add .querydrift/baseline.json
git commit -m "chore: add querydrift baseline"4. Add the check to CI — copy examples/github-actions.yml into your .github/workflows/, or add one step to your existing test job:
- name: querydrift N+1 check
run: npx querydrift checkThat's it. querydrift re-runs your suite internally, so it doesn't need a separate test step. PRs now fail on N+1 regressions.
How it works
test suite runs ─▶ interceptor captures every SQL query, grouped by test
│
▼
normalize: WHERE id = 1 ─▶ where id = ? (literals stripped)
fingerprint each unique query shape
│
┌───────────────┴───────────────┐
▼ ▼
querydrift baseline querydrift check
→ .querydrift/baseline.json → diff current run vs baseline
→ committed to main → fail (exit 1) on regressionQueries are normalized before comparison, so WHERE id = 1 and WHERE id = 2 are the same pattern and counts stay stable across runs. A regression is a known pattern whose count jumps past the threshold (default 3×), or a brand-new pattern that runs ≥3 times. The baseline is committed, so a regression is git blame-able to the PR that introduced it.
Use with AI agents
querydrift is designed to be driven by a coding agent (Claude Code, Cursor, opencode, …), not just read by a human:
- Machine-readable output.
querydrift check --format jsonwrites only JSON to stdout — the test runner's own output is routed to stderr, so an agent canJSON.parsestdout with no scraping. - Self-describing failures. Each failure names the test, the normalized query pattern, baseline-vs-current counts, and (when attributable) the
site— thefile:linethat fired the query. The agent doesn't search for the loop; it's told where it is. Ahintfield spells out the fix. - Standard exit codes.
0pass,1regression,2setup error — an agent reads the result without parsing prose. - No interactivity. Every command runs non-interactively in CI or a sandbox.
$ querydrift check --format json
{
"ok": false,
"testsChecked": 1,
"failures": [
{
"test": "GET /users returns all users with their posts",
"kind": "new",
"normalized": "select id, title from posts where user_id = ?",
"baselineCount": 0,
"currentCount": 12,
"site": { "file": "src/api/users.ts", "line": 42, "fn": "getUserPosts" }
}
],
"warnings": [],
"hint": "Use Prisma's `include` at the top level instead of querying inside a loop. Re-run `querydrift baseline` on main to accept the new pattern."
}A typical automated loop: run check --format json → read failures[].site (or test + normalized when unattributed) → hoist the per-row query at that line into a top-level include/join → re-run until ok: true.
Ready-made agent integrations
This repo ships drop-in commands so your agent runs that loop for you:
- Claude Code — skill (recommended). Copy
.claude/skills/querydrift/into your project's.claude/skills/(or~/.claude/skills/). It auto-activates when Claude sees an N+1 or slow-query context — no command to remember. Claude runscheck --format jsonand fixes the regressions. - Claude Code — slash command. Prefer explicit? Copy
.claude/commands/querydrift.mdinstead; then/querydriftchecks and fixes,/querydrift baselinerecaptures. - opencode. Copy
.opencode/command/querydrift.mdinto your project's.opencode/command/. - Cursor / Codex / any shell-capable agent. Point it at
querydrift check --format json— the same JSON contract works as a rule or command.
These are thin glue over the JSON interface above — no MCP server or plugin runtime required.
CLI reference
querydrift baseline Capture a baseline from the current test run
querydrift check Re-run the suite and fail (exit 1) on a regression| Flag | Command | Default |
|------|---------|---------|
| --test-command <cmd> | both | auto-detected (npx vitest run / npx jest --runInBand) |
| --test-runner <r> | both | auto-detected (vitest | jest) |
| --output <path> | baseline | .querydrift/baseline.json |
| --baseline <path> | check | .querydrift/baseline.json |
| --threshold <n> | check | 3 — N+1 multiplier that triggers a failure |
| --fail-on-warning | check | off — total-count regressions warn but don't fail |
| --format <fmt> | check | text (use json for agents/tooling) |
Exit codes
| Code | Meaning | CI behavior |
|------|---------|-------------|
| 0 | No regressions | passes |
| 1 | N+1 regression detected | fails — blocks merge |
| 2 | Setup error (no baseline, no test runner) | fails — needs a human |
Updating the baseline
When a PR intentionally changes queries, re-run querydrift baseline on the branch and commit the updated file. The baseline diff shows up in the PR, so reviewers see exactly which patterns changed and can judge whether they're expected.
FAQ
Does it need a database in CI? Yes — querydrift runs your real test suite, so it needs whatever your tests need (a Postgres service in CI). It captures the SQL your code actually sends.
Does it slow tests down? Negligibly. The interceptor records the SQL string and delegates the original call untouched; COPY/LISTEN/NOTIFY and friends are unaffected.
MySQL / SQLite / Drizzle / TypeORM? Not in v1. PostgreSQL via Prisma or raw pg only.
Why is a failure missing its Origin: line? Attribution reads the async stack at query time and keeps the first frame in your repo. It's best-effort: Prisma's $on('query') event fires outside your call stack (use setupPg() with a driver adapter for attributed Prisma 7 queries), and some await boundaries lose frames. An unattributed failure still names the test and pattern — detection never depends on attribution.
Is the baseline committed? Yes — .querydrift/baseline.json is your source of truth. Add only the run artifacts to .gitignore:
.querydrift/last-run.json
.querydrift/*.tmpContributing
npm install
npm test # unit tests, no database needed
npm run buildThe demo/ suite runs against a real Postgres — see demo/README.md.
