Skip to main content

sql-sop

CI PyPI Python License Discord Marketplace Playground Downloads codecov

Contributing: CONTRIBUTING.md · ROADMAP.md · GOVERNANCE.md · CODE_OF_CONDUCT.md · SECURITY.md · NOTICE

  • Governed Agent Stack brings together on-prem tools for auditable database agents; sql-sop is a standalone linter that can complement that workflow.
  • sql-steward governs SQL generated by agents through a semantic layer and audit trail, complementing sql-sop's checks on SQL written by people.
  • sql-explorer-mcp provides read-only database exploration over MCP and uses sql-sop to reject dangerous queries before execution.

The dbt-aware rule pack (DBT001+) extends sql-sop into dbt projects. See the ADR for the broader roadmap.

Why Does This Exist?

One bad SQL query can delete production data, expose customer records, or bring down a database. Most teams only find out after the damage is done. sql-sop catches dangerous patterns automatically, before the query ever runs, in 0.08 seconds.

Key Numbers

Rules 48 (9 errors, 25 warnings, 3 structural, 6 T-SQL, 5 Python-source); 53 with --contract, 55 with --dbt
Tests 445
Scan speed 0.08s across 200 files
PyPI installs 2,000+ (mirrors excluded)
Version 0.11.0

Fluent API (v0.2.0)

from sql_guard import SqlGuard

result = SqlGuard().enable("E001", "W001").scan("DELETE FROM users")
print(result.passed)    # False
print(result.summary()) # "1 error, 0 warnings in 1 statement"

48 rules, plus an optional Contracts pack (5 schema-aware rules) under --contract path.yml and a dbt pack (7 rules) under --dbt. Inline disable, project config, git-changed-only mode, and SARIF output for GitHub Code Scanning. Runs as a CLI tool, pre-commit hook, and GitHub Action.

Want the same rules inside your AI assistant? sql-sop-mcp exposes them over MCP, so the model is told a query is unsafe before it suggests it.


Quick start

If sql-sop catches a real bug for you, a GitHub star is the easiest way to help. It makes the project more discoverable for people with the same problem.

pip install sql-sop
sql-sop check .

# Also scan .py files for SQL hazards in execute()/read_sql() calls:
pip install "sql-sop[python]"
sql-sop check . --include-python
queries/create_orders.sql
  L3:  ERROR [E001] DELETE without WHERE clause -- this will delete all rows
         -> Add a WHERE clause to limit affected rows
  L7:  WARN  [W001] SELECT * -- specify columns explicitly
         -> Replace with: SELECT col1, col2, col3 FROM ...

Found 2 issues (1 error, 1 warning) in 1 file (0.001s)

Where sql-sop runs

Most teams have no SQL review process at all. sql-sop gives you one in two places, both driven by the same rules:

  • Pre-commit -- runs in under 0.2s on the files you changed, so a bad DELETE FROM users is blocked before it is ever committed.
  • CI -- runs on every pull request and uploads SARIF, so findings appear inline in the GitHub "Files changed" view through Code Scanning.

Same engine in both, no AI, no API keys, nothing to host. Fast enough to sit in the commit path, strict enough to gate a PR.


Set up (5 minutes)

Step 1: Pre-commit hook

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Pawansingh3889/sql-sop
    rev: v0.11.0
    hooks:
      - id: sql-sop
        args: [--severity, error]  # only block on errors locally
pip install pre-commit
pre-commit install

Now every git commit with .sql changes runs sql-sop automatically. Errors block the commit. Warnings are shown but don't block.

Step 2: GitHub Actions

# .github/workflows/sql-quality.yml
name: SQL Quality
on:
  pull_request:
    paths: ['**/*.sql']

permissions:
  contents: read
  pull-requests: write

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Pawansingh3889/sql-sop@v1
        with:
          severity: warning

That's it. Every SQL change gets an instant, rule-based lint on the PR.

Step 3 (optional): CLI for manual checks

sql-sop list-rules prints every registered rule. See Configuration for the flags that change what is reported.


Rules

Errors (block commit by default)

ID Name What it catches
E001 delete-without-where DELETE FROM orders; -- deletes all rows
E002 drop-without-if-exists DROP TABLE users; -- fails if table missing
E003 grant-revoke GRANT SELECT ON users TO public; -- privilege escalation
E004 string-concat-in-where WHERE id = '' + @input -- SQL injection
E005 insert-without-columns INSERT INTO t VALUES (...) -- breaks on schema change
E006 update-without-where UPDATE orders SET status = 'x'; -- overwrites every row
E007 alter-add-not-null-no-default ALTER TABLE t ADD c INT NOT NULL; -- locks table for full rewrite
E008 drop-column ALTER TABLE t DROP COLUMN c; -- irreversible, breaks subscribers
E009 update-from-without-join UPDATE a SET x = b.x FROM a, b -- comma-separated FROM silently makes a Cartesian product

Warnings (advisory by default)

ID Name What it catches
W001 select-star SELECT * FROM users -- pulls unnecessary columns
W002 missing-limit Unbounded SELECT -- could return millions of rows
W003 function-on-column WHERE YEAR(date) = 2024 -- kills index usage
W004 missing-alias JOIN without table aliases -- hard to read
W005 subquery-in-where WHERE x IN (SELECT ...) -- often slower than JOIN
W006 orderby-without-limit ORDER BY without LIMIT -- sorts entire result
W007 hardcoded-values WHERE amount > 10000 -- use parameters
W008 mixed-case-keywords select ... FROM -- inconsistent casing
W009 missing-semicolon Statement not terminated with ;
W010 commented-out-code -- SELECT * FROM old_table -- use version control
W011 union-without-all UNION between disjoint sets -- forces a deduplication sort, UNION ALL is faster when uniqueness is guaranteed
W012 group-by-ordinal GROUP BY 1, 2 -- fragile to SELECT-list reorders
W013 window-missing-partition OVER () -- unpredictable results and unclear intent
W014 case-without-else CASE WHEN ... THEN ... END -- unmatched rows return NULL
W015 join-function-on-column JOIN customers c ON UPPER(o.email) = UPPER(c.email) -- kills index seek
W016 not-in-with-subquery WHERE id NOT IN (SELECT ...) -- silently returns 0 rows on NULL
W017 leading-wildcard-like WHERE name LIKE '%smith' -- non-SARGable, full scan
W018 or-across-columns WHERE a = 1 OR b = 2 -- defeats single-column indexes
W019 count-distinct-unbounded COUNT(DISTINCT col) with no WHERE / GROUP BY / LIMIT -- full sort + distinct over the whole table
W020 truncate-table TRUNCATE TABLE staging; -- bypasses triggers, resets identity
W021 having-without-group-by HAVING status = 'x' with no GROUP BY -- legal, but usually a misplaced WHERE
W022 cross-join-explicit FROM products CROSS JOIN regions -- Cartesian product, confirm intent
W023 scalar-udf-in-where WHERE dbo.fn_X(col) = 1 -- row-by-row predicate evaluation
W024 select-distinct-suspicious SELECT DISTINCT a, b FROM x JOIN y ON ... -- DISTINCT often masks a missing join condition or GROUP BY
W025 assertion-malformed -- @assert: <predicate> comment whose predicate does not match the sql-sop grammar (row_count <op> <int>, unique(<col>), not_null(<col>), <col> <op> <literal>)

Structural (v0.3.0+, sqlparse-based)

Rules that need an AST view of the statement, parsed via sqlparse. Catch issues that single-line regex matching cannot reliably see.

ID Name What it catches
S001 implicit-cross-join JOIN customers with no ON / USING -- accidental Cartesian product
S002 deeply-nested-subquery Subqueries more than 2 levels deep -- typically a refactor opportunity
S003 unused-cte WITH x AS (...) defined but never referenced

T-SQL (v0.5.0+)

Rules targeting SQL Server anti-patterns common in legacy stored procs and SSRS datasets. Fire on text patterns that do not appear in BigQuery or Postgres code, so they run unconditionally with near-zero false positives on non-T-SQL input.

ID Name What it catches
T001 with-nolock SELECT * FROM t WITH (NOLOCK) -- dirty reads
T002 xp-cmdshell EXEC xp_cmdshell ... -- shell-exec surface
T003 cursor-declaration DECLARE c CURSOR FOR ... -- row-by-row processing
T004 deprecated-outer-join WHERE a.x *= b.y -- removed in SQL Server 2012+
T005 create-index-without-online CREATE INDEX ix ON t (...) -- locks table; add WITH (ONLINE = ON)
T006 select-into-without-typed-fields SELECT * INTO target FROM source -- destination schema is inferred at runtime

Contracts (opt-in via --contract)

Pass --contract path/to/contract.yml (or set contract: in .sql-guard.yml) to lint queries against the expected schema. Without a contract these rules are silent. Format is a thin subset of the open data-contract space; see tests/fixtures/contract_sample.yml for a working example.

ID Name What it catches
C001 column-not-in-contract SELECT o.bogus FROM orders o -- column not declared for that table
C002 table-not-in-contract SELECT * FROM ghost_table -- table absent from the contract
C003 not-null-violation INSERT INTO orders (id) VALUES (1) -- omits a NOT NULL column
C004 primary-key-missing-on-insert INSERT omits a PK column with no default
C005 unmapped-fk JOIN ... ON o.id = c.id -- columns have no FK relationship in the contract

Two helper subcommands round out the workflow:

# Bootstrap a contract from an existing database (requires sql-sop[snapshot]):
sql-sop schema-snapshot \
  --dsn "mssql+pyodbc://user:pass@host/db?driver=ODBC+Driver+18+for+SQL+Server" \
  --output contract.yml

# Validate a contract YAML structure before running rules (CI-friendly):
sql-sop validate-contract --contract contract.yml

Python scanning (v0.4.0+, opt-in)

Enable with pip install "sql-sop[python]" and --include-python. Uses libCST to walk Python source and extract SQL strings from .execute(), .read_sql(), sqlalchemy.text(...) calls and sql =/query = style assignments. Then applies every rule above, plus five that only make sense at the Python level:

ID Name What it catches
P001 fstring-in-execute cursor.execute(f"... {user_input}") -- SQL injection
P002 concat-in-execute cursor.execute("..." + user_input) -- SQL injection
P003 format-in-execute .format() or % interpolation into an execute call
P004 bare-variable-in-execute cursor.execute(query) where query is an unchecked variable
P005 sqlalchemy-text-fstring sqlalchemy.text(f"... {var}") -- SQL injection on the SQLAlchemy text() surface

dbt (v0.10.0+, opt-in via --dbt)

Pass --dbt to activate the dbt-aware rule pack. sql-sop walks up from the first checked path to find dbt_project.yml and reads schema.yml at lint time; with no project found the pack stays silent, so existing users see no behaviour change. Rules only fire on .sql files inside the project's model-paths -- macros, analyses, seeds and snapshots are skipped.

ID Name What it catches
DBT001 model-without-test Model absent from schema.yml, or declared with neither tests: nor data_tests:
DBT002 direct-table-ref Raw table name in FROM/JOIN instead of ref() / source() -- breaks lineage
DBT003 incremental-without-unique-key materialized='incremental' with no unique_key. Error on merge / delete+insert (silent duplicate rows), warning when the strategy is unset, silent on append / insert_overwrite
DBT004 hook-with-ddl pre_hook / post_hook containing destructive or structural DDL
DBT005 select-star-in-mart SELECT * in a mart model -- downstream contracts drift silently
DBT006 model-without-description Model has no description: in schema.yml
DBT007 unquoted-var-interpolation {{ var(...) }} interpolated into SQL without surrounding quotes -- breaks the day the var holds whitespace or a quote
sql-sop check models/ --dbt

Configuration

Disable specific rules

sql-sop check . --disable E002 W008 W010

Project config file (.sql-guard.yml)

Drop a .sql-guard.yml (or .sql-guard.yaml) at the repo root. The loader walks up from the current directory; CLI flags merge with and override these settings.

disable:
  - W005
  - T001
ignore:
  - migrations/legacy/
  - vendor/
include_python: true
severity: warning

Inline disable comments

Silence a known false positive on a single line, no project-wide override needed:

SELECT * FROM lookups; -- sql-guard: disable=W001
SELECT * FROM users  -- sql-guard: disable=W001,W002
WHERE name LIKE '%smith';

-- sql-guard: disable-next-line=W017
SELECT * FROM events WHERE name LIKE '%checkout';

A bare -- sql-guard: disable (no equals sign) silences every rule on the line. The same directives work in Python with # instead of --.

Lint only changed files

For pre-commit and CI on big repos:

sql-sop check . --changed-only                      # working tree
sql-sop check . --changed-only --changed-base main  # vs a branch ref

Falls back to a full scan with a warning when not in a git repo.

Severity filtering

sql-sop check . --severity error    # only show errors
sql-sop check . --severity warning  # show everything (default)

Fail fast

sql-sop check . --fail-fast  # stop after first error found

SARIF output for GitHub Code Scanning

Render findings inline on PRs in the GitHub Files Changed view:

sql-sop check . --format sarif --output results.sarif

In a GitHub Actions workflow:

- run: sql-sop check . --format sarif --output sql-guard.sarif
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: sql-guard.sarif

Performance

sql-sop is designed to be fast:

  • Compiled regex -- patterns compiled once at startup, reused per file
  • Two-pass scanning -- single-line rules run first (16 of 43 SQL rules), multi-line parsing only when needed
  • Line-by-line streaming -- files read line by line, not loaded entirely into memory
  • Early exit -- --fail-fast stops on first error
Benchmark: 200 SQL files, 20 SQL rules
  sql-guard:  0.08 seconds
  sqlfluff:   45 seconds (560x slower)

Production Use Case

In a regulated data environment, sql-sop runs as a pre-commit hook on all SQL that touches the database. Combined with read-only database users and container isolation, it forms part of a layered safety setup that prevents accidental writes to production.


How it compares

sql-sop sqlfluff sql-lint
Rules 48 (focused) 800+ (comprehensive) ~20
Speed <0.1s for 200 files 45s for 200 files ~2s
Config needed Zero Extensive Minimal
Language Python Python JavaScript
Pre-commit Yes Yes No
GitHub Action Yes Community No
AI integration MCP server (sql-sop-mcp) No No

sql-sop is not a replacement for sqlfluff. It's a fast first pass that catches 80% of real issues with zero setup. If you need dialect-specific formatting and 800 rules, use sqlfluff. If you want instant feedback on dangerous SQL, use sql-sop.


Contributing

git clone https://github.com/Pawansingh3889/sql-sop.git
cd sql-sop
pip install -e ".[dev]"
pytest

Adding a new rule

  1. Create a class in sql_guard/rules/errors.py or warnings.py
  2. Inherit from Rule, set id, name, severity, description
  3. Override check_line() for single-line rules or check_statement() for multi-line
  4. Add to ALL_RULES in sql_guard/rules/__init__.py
  5. Add a test in tests/test_rules.py
  6. Add a trigger case in tests/fixtures/
class MyNewRule(Rule):
    id = "W011"
    name = "my-rule"
    severity = "warning"
    description = "What this rule catches"
    multiline = False

    _pattern = Rule._compile(r"your regex here")

    def check_line(self, line, line_number, file):
        if self._pattern.search(line):
            return Finding(
                rule_id=self.id,
                severity=self.severity,
                file=file,
                line=line_number,
                message="What went wrong",
                suggestion="How to fix it",
            )
        return None

PRs welcome. Keep rules simple, keep patterns fast.


Contributors

Thank you to the people who have shipped rules and code to sql-sop.

Contributor Contribution
@tmchow W011 union-without-all. Flags UNION where UNION ALL would be safe and faster.
@tmchow P005 sqlalchemy-text-fstring. Catches sqlalchemy.text(f"...{var}") patterns that defeat parameter binding.
@mvanhorn W019 count-distinct-unbounded. Flags COUNT(DISTINCT col) without WHERE, GROUP BY, or LIMIT.
@mvanhorn W015 join-function-on-column. JOIN-side companion to W003. Flags function calls wrapping columns inside JOIN ... ON predicates.
@mvanhorn W023 scalar-udf-in-where. Flags schema-qualified scalar UDF calls inside WHERE, HAVING, and ON predicates.
@Prabhu-1409 W013 window-without-partition. Flags OVER () without PARTITION BY, dialect-aware messaging for Postgres and Redshift.
@hellozzm W014 case-without-else. Walks CASE/END token-by-token; catches outer CASE without ELSE even when an inner CASE does have one.
@vibeyclaw W022 cross-join-explicit. Flags explicit CROSS JOIN. Strips trailing line comments before matching to avoid false positives on commentary.

See the full contributors graph on GitHub.

Want to add your name here? Pick a good first issue, follow CONTRIBUTING.md, and check the roadmap for the next batch of rules.


License

MIT

Metadata

Release files for sql-sop 0.11.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sql-sop 0.11.0
File Size Uploaded
sql_sop-0.11.0.tar.gz 176.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sql-sop 0.11.0
File Interpreter ABI Platform
sql_sop-0.11.0-py3-none-any.whl Python 3 none any Details

Total release size: 244.2 kB

Release files / sql_sop-0.11.0.tar.gz

Download URL sql_sop-0.11.0.tar.gz
Size 176.0 kB
Tags Source
SHA-256 checksum
How to use checksums
78326e58ca5497f93a0b851516d2a8175d533f3c4635e023e85fd7188b0e4ef3
BLAKE2b-256 checksum
How to use checksums
a25574203d0373c01291f68d123e8c977d55648e8de53a45d332bad75140a2dd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.

Transparency log

Release files / sql_sop-0.11.0-py3-none-any.whl

Download URL sql_sop-0.11.0-py3-none-any.whl
Size 68.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8aefe09d21ff417aad73a2829c2fb858de728cf54af96e24dbc3494a2d5c0ae1
BLAKE2b-256 checksum
How to use checksums
03af6730c3588dede960ef38efda7f96a04d7f2b98e854b523e09db3d85b517a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.11.0 This release

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page