Skip to content
SQLFluff Tutorial: Lint and Format SQL in CI

Click to use (opens in a new tab)

SQLFluff Tutorial: Lint and Format SQL in CI

September 3, 2026 by Chat2DBChat2DB Team

Most 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 -20

Lint 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 postgres

A 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:

PrefixFamilyWhat it covers
LTLayoutIndentation, whitespace, line length, commas
CPCapitalisationKeywords, functions, identifiers, data types
ALAliasingTable and column alias style, implicit aliasing
AMAmbiguousSELECT *, ambiguous ORDER BY, implicit joins
CVConvention!= vs <>, COALESCE vs IFNULL, terminators
RFReferencesQualification, consistency, keywords used as names
STStructureRedundant ELSE NULL, subqueries that should be joins
TQTemplatingJinja 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 postgres

Take 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 = ansi

A few notes on choices that trip people up:

  • capitalisation_policy = consistent is the default and it is a trap on a legacy codebase: it locks in whatever the first file happens to use. Set upper or lower explicitly.
  • RF04 flags identifiers that collide with reserved keywords. On an inherited schema full of columns called type and status this produces hundreds of violations you cannot act on, so excluding it is reasonable.
  • aliasing = explicit requires FROM orders AS o rather than FROM 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-03

For 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 = ci

The 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
fi

Step 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, RF02

Step 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-revs

Step 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-native

Pinning 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=LT05

Where 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.