PostgreSQL migration lock linter and synthetic query-plan smoke test.
Click Run checks to preview the report.
This preview runs a simplified version of the rules in your browser. It does not connect to a database, so it cannot apply your migration, report a statement that fails, or run EXPLAIN. The CLI and the GitHub Action are more thorough.
Runs locally without a database connection, Docker container, or credentials, so it fits in a git hook. It applies three rules: CREATE INDEX without CONCURRENTLY, ALTER COLUMN … TYPE, and CREATE INDEX CONCURRENTLY inside a transaction.
Test individual migration files directly before staging:
# Run static lock inspection against any SQL migration
npx queryguard --lint --migration path/to/migration.sql
| Exit Code | Status | Action |
|---|---|---|
0 | NO FINDINGS | None of the three rules matched. This is not proof that the migration is safe. |
1 | HAZARD | Found a SHARE lock, an ACCESS EXCLUSIVE lock, or CONCURRENTLY inside a transaction. Prints remediation SQL. |
Drop this snippet into .husky/pre-commit or .git/hooks/pre-commit to prevent dangerous DDL from being committed:
# .husky/pre-commit
git diff --cached --name-only --diff-filter=ACM | grep -E '\.sql$' | while read -r file; do
npx queryguard --lint --migration "$file" || exit 1
done
AI coding agents (Cursor, Claude Code, Windsurf, Devin) frequently generate non-concurrent indexes. Commit this standard to your repo root so they run the linter and self-correct before marking tasks complete. It steers the agent; it does not enforce anything.
# Commit this file to the root of your repository as: AGENTS.md
# Database Safety Guidelines for AI Coding Agents
When authoring or modifying database schemas, migrations, or database queries:
## 1. Migration Lock Rules
- **Indexes:** Never generate a plain CREATE INDEX on an existing table (acquires a SHARE lock, blocking writes).
- Use CREATE INDEX CONCURRENTLY **only if the migration does not run inside a transaction.** CONCURRENTLY fails inside a transaction block, and many runners wrap every migration in one (Rails and Django do by default), as does an explicit BEGIN ... COMMIT in the file.
- Not in a transaction: CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
- In a transaction: do not just add CONCURRENTLY. Opt the migration out first (Rails: disable_ddl_transaction!; Django: atomic = False), or put the index in its own migration that runs outside a transaction.
- **Column Rewrites:** Never alter a column type in place via ALTER TABLE ... ALTER COLUMN ... TYPE ... (acquires ACCESS EXCLUSIVE lock, blocking all reads and writes). Stage transitions using a new nullable column.
## 2. Pre-Completion Validation Step
Before marking any task complete that creates or modifies a migration file:
1. Run the static lock linter:
npx queryguard --lint --migration <path-to-migration-file>
(add --assume-in-transaction if your runner wraps each file in a transaction)
2. **Self-Correction Rule:**
- If the linter exits with code 1, inspect the reported [SHARE], [ACCESS EXCLUSIVE] or [TRANSACTION] hazard.
- Apply the recommended fix. For a [TRANSACTION] hazard, move the statement out of the transaction; do not remove CONCURRENTLY.
- Re-run the command until it exits with code 0.
Runs inside an ephemeral PostgreSQL container on your GitHub Actions runner. It applies your baseline schema and migration (reporting the first statement that fails), then fills tables with generated rows and runs EXPLAIN on your queries. Sequential scans are informational only. The PR comment is updated in place and leads with a status: PASS, FAIL or INCONCLUSIVE.
Run these test cases on a test branch to verify lock detection, failure reporting, the query-plan smoke test, and in-place comment updates.
Add a non-concurrent index to your migration file: CREATE INDEX idx_test ON users(email);
• Expected result: QueryGuard flags a SHARE lock hazard and comments the exact CREATE INDEX CONCURRENTLY replacement.
Add a statement that errors, for example ALTER TABLE does_not_exist ADD COLUMN x int;
• Expected result: status FAIL, with the statement, its line number and the Postgres error.
Add an unindexed filter query to your queries file: SELECT * FROM users WHERE unindexed_col = 'val';
• Expected result: QueryGuard fills tables with generated rows, runs EXPLAIN, and lists the scan under Informational with an optional index suggestion. It does not change the status or fail the build.
Push a second commit fixing the issues. QueryGuard updates its original comment in place instead of adding a new one.
• Runs on your compute: on your GitHub Actions runner and a temporary Postgres container.
• No third-party services: the only network calls are to the GitHub API, to post the PR comment. The comment and job summary contain your migration and query statements and table names, so they are as visible as the pull request.
• Synthetic rows only: your data is never read.