CI Quickstart
The Collector is a container your workflow runs as one step. It captures the queries your tests ran against a real PostgreSQL database and sends them to Query Doctor, which plans them and comments on the pull request: queries that got slower, new queries an index or a rewrite would make cheaper, new queries that return wrong results, data-access changes with no test, schema drift.
Prerequisites
Section titled “Prerequisites”- GitHub Actions on an
ubunturunner - A test suite that hits a real PostgreSQL database
contents: readandpull-requests: writepermissions on the job- A Query Doctor project with a token
Inputs
Section titled “Inputs”Set these on the Query Doctor step:
| Input | Description |
|---|---|
token |
See Get your project token. |
database-url |
The database your tests ran against (postgres://user@localhost/db). |
endpoint |
Where your Query Doctor is. Defaults to https://cloud.querydoctor.com. See Run Query Doctor. |
Get your project token
Section titled “Get your project token”Open your project and go to Setup → Continuous integration. Copy the value from the “Analyzer token” panel.
Store it as an Actions secret, under Settings → Secrets and variables → Actions in the repo or with the gh CLI:
gh secret set QUERYDOCTOR_TOKEN --body "<token>" --repo <owner>/<repo>The job below is a Node project’s. Step 1 adds pg_stat_statements to your Postgres and step 5 runs the Collector; steps 2 to 4 are your own install, migrate and test steps, so use whatever your project runs.
Workflow trigger
Section titled “Workflow trigger”Trigger on pull_request, and on push to whichever branches your pull requests target. A pull request is compared against the newest run on its base branch, so that branch needs runs of its own.
on: pull_request: push: branches: [main] # and any other branch your pull requests target1. Add a postgres service
Section titled “1. Add a postgres service”jobs: query-doctor: runs-on: ubuntu-latest permissions: contents: read pull-requests: write services: postgres: image: postgres:17 # the major your production database runs env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: myapp_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 command: >- postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all steps: - uses: actions/checkout@v7 - name: Enable pg_stat_statements run: | psql -h localhost -U postgres -d myapp_test \ -c "CREATE SCHEMA IF NOT EXISTS ext;" \ -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements SCHEMA ext;" env: PGPASSWORD: postgrestrack=all records queries called inside functions, which the default skips. Queries inside rolled-back transactions are recorded too.
CI should run the same Postgres major as production. Another major records the same schema differently (Postgres 18 lists each NOT NULL column as a constraint) and plans on another planner, so every cost and schema comparison would cross versions. SHOW server_version_num on your production database gives the major: 170004 is 17.
The image decides which extensions exist, so a schema that declares vector, vchord or postgis will not migrate under stock postgres. Use the image your own database runs, the one your docker-compose.yml or Helm values name.
2. Install your dependencies
Section titled “2. Install your dependencies”- uses: actions/setup-node@v7 with: node-version: 24- name: Install dependencies run: npm ci3. Run migrations and seed
Section titled “3. Run migrations and seed”- name: Migrate and seed run: npm run migrate && npm run seed env: POSTGRES_URL: postgres://postgres:postgres@localhost/myapp_test4. Run your test suite
Section titled “4. Run your test suite”- name: Run tests run: npm run test:integration env: POSTGRES_URL: postgres://postgres:postgres@localhost/myapp_test5. Run Query Doctor
Section titled “5. Run Query Doctor”- name: Run Query Doctor uses: query-doctor/ci@v1 with: token: ${{ secrets.QUERYDOCTOR_TOKEN }} database-url: postgres://postgres:postgres@localhost/myapp_test endpoint: <your-query-doctor-url>The action asks your Query Doctor instance which Collector to run, then pulls and runs it on the runner’s network, so the database is at localhost as it was for your tests. The step fails when the check does, and also when it cannot reach your instance, the token is rejected, or the Collector cannot be pulled.
endpoint is the address of your Query Doctor instance: https://cloud.querydoctor.com on the hosted service, or the address you open your instance at if you self-host.
The check
Section titled “The check”What fails the check
Section titled “What fails the check”The step goes red on any of these:
- Untested data access. The PR changes data-access code and no real-DB test changed with it. Raw SQL in a string counts only when it names a table or view in the schema your tests ran against, so example SQL for tables you don’t have is left alone. Add a test that runs those queries against Postgres, or set the check to
warnoroff(see Per-repo configuration). - Cost regression. A query’s cost rose past the regression threshold against the baseline. Acknowledge or resolve the query to stop it blocking.
- New query with an improvement. A new query ships with an index or a rewrite that would cut its cost by at least half, saving more than 100. A smaller saving shows on the run page and doesn’t block. Add the index or apply the rewrite, or acknowledge or resolve the query. A rewrite found after the index search ran out of time counts too, and the comment says no index was costed. A rewrite that is cheaper only with a new index counts as well, and its line in the comment names that index.
- Schema drift. The schema changed against the baseline. Approve the change, or set the check to
warnoroff. A change of Postgres major between the baseline and the run is drift too: the check names it (Postgres 16 → 18), and approving it clears the gate like a migration. The list leaves out what the new major only records differently, such as the NOT NULL constraints Postgres 18 lists for each required column.
A correctness finding flags a new query that can return wrong or unstable results: a comparison to NULL with =, a LIMIT without an ORDER BY, or a join with no condition. It warns by default, because the rules read the SQL text alone and can’t see your schema or what you meant. To block on it, set it to fail under Per-repo configuration. It shows even when the query costs less than the minimum query cost. The comment names the first 10 such queries and what is wrong in each, then counts the rest. Fix the query, or acknowledge or resolve it.
Require the check in a branch ruleset
Section titled “Require the check in a branch ruleset”A red check doesn’t stop a merge on its own. Require the job as a status check, once per repo.
1. Get the check’s name
Section titled “1. Get the check’s name”The check is the job holding the Collector step, query-doctor in the workflow above, but a matrix or a reusable workflow changes the name. Read it off a pull request that already ran:
gh pr checks <pr-number> --json name --jq '.[].name'2. Create the ruleset with the gh CLI
Section titled “2. Create the ruleset with the gh CLI”Put your check name in context and run:
gh api repos/<owner>/<repo>/rulesets --method POST --input - <<'JSON'{ "name": "main: require Query Doctor", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": false, "required_status_checks": [{ "context": "query-doctor" }] } } ]}JSONstrict_required_status_checks_policy is required: false lets a pull request merge without pulling in the latest base commit, true requires an update and a fresh run first.
To see what the repo has now:
gh api repos/<owner>/<repo>/rulesets --jq '.[] | "\(.id) \(.name) \(.enforcement)"'Or create it in the GitHub UI
Section titled “Or create it in the GitHub UI”Go to Settings → Rules → Rulesets, then New ruleset → New branch ruleset:
- Name it, for example
main: require Query Doctor. - Set Enforcement status to Active.
- Under Target branches, click Add a target → Include default branch.
- Tick Require status checks to pass.
- Click Add checks, type the check name from Get the check’s name, and select it from the list.
- Click Create.
The check appears in that search box only after it has reported at least once. If it isn’t there, open a pull request, let the workflow finish, and come back.
Before you require it
Section titled “Before you require it”- The check covers the whole job. A failing test step reds it too.
- A required check that never reports blocks the merge. Don’t require the job if a path filter or an
if:condition can skip it. Pull requests from forks get no secrets, so there the step fails rather than skips.
PR comment behavior
Section titled “PR comment behavior”One comment per pull request, updated on every run.
It opens with a count, such as 1 failing and 3 successful checks. Each condition then gets a row naming what it concluded and what it found, blocking ones first.
A NOT RUN row says why that condition could not be decided, and it counts for nothing. The correctness row is NOT RUN when the SQL text of an unacknowledged new query was not read and no new query was flagged for wrong or unstable results.
Below the roster, a note names the tables your statistics export did not cover, the first 10 of them and a count of the rest. Postgres was given an estimated row count for each, so a query touching one is not a clean pass however green its row reads. A table added to production since your last statistics sync is covered by the next one. A table this pull request creates stays listed until it ships.
Query triage
Section titled “Query triage”Open a query from a run and set its status:
| Status | What the check does with it |
|---|---|
| New (default) | Gates as normal. |
| Acknowledged | Stops gating. Give it an expiry and it gates again once that passes. |
| Resolved | Stops gating, and never expires. |
Per-repo configuration
Section titled “Per-repo configuration”On the same Setup → Continuous integration page, pick the repo:
| Setting | Description | Default |
|---|---|---|
| Minimum query cost to report | Queries below this cost are excluded from PR comments and regression checks. A new query with a correctness finding shows anyway. | 0 (show all) |
| Regression threshold | Only flag regressions above this percentage increase. | 5% |
| Statistics scale | Model query plans at this multiple of your current data size. It changes results only when the project has production statistics on file. | 1× |
| Comparison branch | Branch to compare against for regressions (e.g. main, staging). Leave it empty and a pull request compares against its base branch. |
Base branch |
Under Gate Policies, each condition is fail, warn or off:
| Condition | What it catches | Default |
|---|---|---|
| Cost regression | A query’s plan cost rose past the regression threshold. | fail |
| Untested data access | A changed data-access file ships with no related test. | fail |
| New query | A query with no prior baseline was introduced. | warn |
| New query with an improvement | An index or a rewrite would cut a new query’s cost by at least half, saving more than 100. | fail |
| Schema drift | The run’s schema changed against the baseline. | fail |
| Correctness finding | A new query can return wrong or unstable results. | warn |
| Analysis stalled | Query Doctor stopped short of a verdict on this run. | fail |
These settings are also available via the API. Reading the config takes no credentials. Updating it takes a personal access token, which you create on your Account page:
# Read configcurl <your-query-doctor-url>/ci/repos/org%2Frepo/config
# Update configcurl -X PUT <your-query-doctor-url>/ci/repos/org%2Frepo/config \ -H 'Authorization: Bearer <your-token>' \ -H 'Content-Type: application/json' \ -d '{"minimumCost": 10, "regressionThreshold": 20, "comparisonBranch": "main", "conditionPolicies": {"untested-data-access": "warn", "schema-drift": "off"}}'Production statistics (optional)
Section titled “Production statistics (optional)”With no production statistics on file, Query Doctor plans your queries against an assumed table size rather than a measured one. Push real statistics and every cost reflects the data your queries actually meet. The PR comment names which a run used.
Connect the database whose numbers you want under Setup → Connected databases. Its statistics are read on each check-in, and your next CI run is planned against the newest ones.
Table statistics include values Postgres sampled out of your columns, so read what leaves your database before connecting production.