SQLFluff Tutorial: Lint and Format SQL in CI
Chat2DB TeamMost teams review SQL the same way they reviewed it fifteen years ago: someone reads the diff and comments "can you uppercase the keywords" and "this should be a join, not a subquery". That is a waste of a reviewer. SQLFluff is a linter and auto-formatter for SQL that moves the mechanical half of that review into CI, where it belongs.
Unlike a plain formatter, SQLFluff actually parses your SQL into a syntax tree using a dialect grammar. That is what lets it distinguish a keyword from a column named order, and what lets it fix violations rather than just report them.
Installing and running SQLFluff
SQLFluff is a Python package:
pip install sqlfluff
# check the version and the dialects available
sqlfluff --version
sqlfluff dialects | head -20Lint a single file. The dialect is mandatory — SQLFluff will refuse to guess, because #> means something in PostgreSQL and nothing in T-SQL:
sqlfluff lint models/orders.sql --dialect postgresA typical first run on an existing codebase looks like this:
== [models/orders.sql] FAIL
L: 1 | P: 1 | LT02 | Expected indent of 0 spaces.
L: 2 | P: 3 | CP01 | Keywords must be consistently upper case.
L: 4 | P: 10 | AM04 | Query produces an unknown number of result columns.
L: 7 | P: 1 | LT09 | Select targets should be on a new line unless there is
| only one select target.
L: 12 | P: 22 | RF02 | Unqualified reference 'status' found in select with more
| than one referenced table/view.Each violation carries a rule code. The prefixes are worth memorising because they tell you how much you should care:
| Prefix | Family | What it covers |
|---|---|---|
LT | Layout | Indentation, whitespace, line length, commas |
CP | Capitalisation | Keywords, functions, identifiers, data types |
AL | Aliasing | Table and column alias style, implicit aliasing |
AM | Ambiguous | SELECT *, ambiguous ORDER BY, implicit joins |
CV | Convention | != vs <>, COALESCE vs IFNULL, terminators |
RF | References | Qualification, consistency, keywords used as names |
ST | Structure | Redundant ELSE NULL, subqueries that should be joins |
TQ | Templating | Jinja and dbt-specific checks |
LT and CP are cosmetic and fully auto-fixable. AM, RF and ST are the ones that catch real bugs — AM04 above is warning you that a SELECT * makes the column list of that model unpredictable, which is exactly the failure mode that breaks a downstream table months later.
Fixing violations automatically
The reason to adopt SQLFluff rather than a style guide document is fix:
# show what would change
sqlfluff fix models/orders.sql --dialect postgres --check
# apply it
sqlfluff fix models/orders.sql --dialect postgresTake this input:
select o.id,o.status, c.email, sum(oi.quantity) as total_items
from orders o
join order_items oi on oi.order_id=o.id
join customers c on c.id=o.customer_id
where o.created_at >= '2026-01-01' and o.status<>'cancelled'
group by o.id,o.status,c.email
having sum(oi.quantity) > 5
order by total_items desc;After sqlfluff fix with the default rules:
SELECT
o.id,
o.status,
c.email,
SUM(oi.quantity) AS total_items
FROM orders AS o
INNER JOIN order_items AS oi ON oi.order_id = o.id
INNER JOIN customers AS c ON c.id = o.customer_id
WHERE
o.created_at >= '2026-01-01'
AND o.status != 'cancelled'
GROUP BY
o.id,
o.status,
c.email
HAVING SUM(oi.quantity) > 5
ORDER BY total_items DESC;Note that fix only touches rules it can safely rewrite. AM04 (SELECT *) is never auto-fixed, because SQLFluff cannot know your table's columns — it reports and leaves it to you.
Configuring with .sqlfluff
Put a .sqlfluff file at the root of the repository so nobody has to remember command-line flags:
[sqlfluff]
dialect = postgres
templater = raw
max_line_length = 120
exclude_rules = LT05, RF04
[sqlfluff:indentation]
indented_joins = false
indented_ctes = false
tab_space_size = 4
[sqlfluff:layout:type:comma]
line_position = trailing
[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper
[sqlfluff:rules:capitalisation.identifiers]
extended_capitalisation_policy = lower
[sqlfluff:rules:capitalisation.functions]
extended_capitalisation_policy = upper
[sqlfluff:rules:aliasing.table]
aliasing = explicit
[sqlfluff:rules:convention.not_equal]
preferred_not_equal_style = ansiA few notes on choices that trip people up:
capitalisation_policy = consistentis the default and it is a trap on a legacy codebase: it locks in whatever the first file happens to use. Setupperorlowerexplicitly.RF04flags identifiers that collide with reserved keywords. On an inherited schema full of columns calledtypeandstatusthis produces hundreds of violations you cannot act on, so excluding it is reasonable.aliasing = explicitrequiresFROM orders AS orather thanFROM orders o. Pick one and let the tool enforce it.
Working with dbt and Jinja
If your SQL contains {{ ref('orders') }}, a raw parser sees garbage. SQLFluff has two templaters for this. The jinja templater renders the template with values you supply, and the dbt templater invokes dbt itself so that ref() resolves against your real project.
[sqlfluff]
templater = jinja
[sqlfluff:templater:jinja:context]
schema_name = analytics
run_date = 2026-09-03For dbt, install the adapter that matches your warehouse and switch the templater:
pip install "sqlfluff-templater-dbt" dbt-postgres[sqlfluff]
templater = dbt
[sqlfluff:templater:dbt]
project_dir = ./
profiles_dir = ~/.dbt
profile = analytics
target = ciThe dbt templater is much slower because it compiles the project, so many teams run the jinja templater locally for fast feedback and the dbt templater in CI.
Adopting it on an existing codebase
Running SQLFluff on a mature repository will produce thousands of violations, and a pull request that reformats every file is unreviewable. The staged approach that works:
Step 1 — lint only changed files. In CI, diff against the base branch and pass just those paths:
CHANGED=$(git diff --name-only --diff-filter=ACM origin/main... -- '*.sql')
if [ -n "$CHANGED" ]; then
sqlfluff lint $CHANGED
fiStep 2 — start with a narrow rule set. Enable only the rules you are ready to enforce, then widen over time:
[sqlfluff]
rules = CP01, CP02, CP03, LT01, LT02, LT12, AM04, RF02Step 3 — bulk-fix layout in one isolated commit. Once the team agrees, run sqlfluff fix across the repository in a single commit that changes nothing but formatting, and add its hash to .git-blame-ignore-revs:
sqlfluff fix . --dialect postgres --force
git commit -am "style: apply sqlfluff formatting"
echo "$(git rev-parse HEAD)" >> .git-blame-ignore-revsStep 4 — switch CI from changed-files to the whole repo and drop the rules allowlist.
Wiring it into CI
A GitHub Actions job that gates pull requests:
name: sql-lint
on:
pull_request:
paths: ['**/*.sql']
jobs:
sqlfluff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install sqlfluff==3.2.5
- name: Lint changed SQL
run: |
CHANGED=$(git diff --name-only --diff-filter=ACM origin/${{ github.base_ref }}... -- '*.sql')
if [ -z "$CHANGED" ]; then echo "no SQL changed"; exit 0; fi
sqlfluff lint $CHANGED --format github-annotation-nativePinning the exact version matters: a minor SQLFluff release can add rules, and an unpinned linter will fail builds on code nobody touched.
For local feedback before you push, a pre-commit hook is the natural pair:
repos:
- repo: https://github.com/sqlfluff/sqlfluff
rev: 3.2.5
hooks:
- id: sqlfluff-fix
additional_dependencies: ['dbt-postgres', 'sqlfluff-templater-dbt']Suppressing what you cannot fix
Some violations are wrong for a specific line. Use a noqa comment rather than disabling the rule globally:
-- exclude a single rule on one line
SELECT * FROM audit_snapshot; -- noqa: AM04
-- exclude everything on one line
SELECT 1 FROM dual; -- noqa
-- turn a rule off for a range
-- noqa: disable=LT05
SELECT some_extremely_long_expression_that_will_not_wrap_sensibly FROM t;
-- noqa: enable=LT05Where a linter stops
SQLFluff parses SQL; it does not connect to your database. It cannot tell you that the column you selected does not exist, that your WHERE clause defeats the index you built for it, or that the join you wrote fans out to ten million rows. Those need a real connection and an execution plan. Once SQLFluff has passed, run the query against the database and read the plan — EXPLAIN (ANALYZE, BUFFERS) on PostgreSQL. If you want linting, execution and plan inspection in one place, Chat2DB (opens in a new tab) is an AI SQL client that formats and explains queries against a live connection across PostgreSQL, MySQL, Oracle and 20+ other engines; there is also a browser version at app.chat2db.ai (opens in a new tab).
For a quick check on a single statement without installing anything, our SQL Linter & Style Checker (opens in a new tab) runs a similar rule set entirely in the browser.
Summary
SQLFluff earns its place by making SQL style a build artifact rather than a review conversation. Start with dialect and a small rules allowlist in .sqlfluff, lint only changed files in CI, do the bulk reformat as one isolated commit, then widen the rule set. The layout rules pay for themselves immediately in cleaner diffs; the AM, RF and ST families are where the tool starts catching real defects.
