Checking queries
sqlsift check parses each query file, resolves every table and column reference against the schema, and infers expression types to find mismatches.
Choosing files
Query files come from the command line or from files in sqlsift.toml. Both accept glob patterns; ** matches any number of directories. Directories a pattern matches are skipped ('queries/**/*' checks the files under queries/); a directory named on its own is an error.
sqlsift check 'queries/**/*.sql'
files = ["app/queries/**/*.sql", "db/reports/*.sql"]
To skip files, add ignore patterns (or --ignore on the command line). They apply to files from both sources:
ignore = ["queries/archive/**", "**/*.generated.sql"]
A pattern that matches a directory skips everything below it. Patterns in sqlsift.toml are relative to the file's directory; --ignore patterns are relative to the current directory and add to the file's list.
Reading from stdin
Pass - as the file name to read a single query from stdin. --stdin-filename sets the file name shown in diagnostics (default <stdin>):
git show :queries/users.sql | sqlsift check -s schema.sql --stdin-filename queries/users.sql -
This is how editors and pre-commit hooks can check content that isn't saved on disk.
File encoding
Query and schema files are read as UTF-8. A byte order mark at the start of a file (written by many Windows editors, SSMS and DBeaver exports) is ignored, both in files and on stdin. Columns in diagnostics don't count it, matching what an editor shows.
Very long expressions, such as generated WHERE id = 1 OR id = 2 OR ... filters with tens of thousands of terms, are fine: analysis runs with a large stack.
What a query can see
sqlsift follows SQL's visibility rules rather than just matching names:
- Table aliases, CTEs (including recursive CTEs) and derived tables
- Correlated subqueries in
WHERE,SELECTandHAVING LATERALvs non-LATERALsubqueries inFROMJOIN ... USINGandNATURAL JOINcolumnsORDER BYreferences toSELECTaliases (also inHAVINGwith MySQL and SQLite, which allow it; PostgreSQL doesn't)UPDATE ... FROMandDELETE ... USING- Table-valued functions in
FROM(for examplegenerate_series)
DDL inside query files
Query files can create their own tables. CREATE [TEMP | UNLOGGED] TABLE, CREATE TABLE ... AS SELECT [WITH [NO] DATA], SELECT ... INTO [TEMP] t, CREATE VIEW, ALTER TABLE and DROP statements in a query file apply to the later statements of that file only:
CREATE TEMP TABLE recent_orders AS
SELECT id, user_id FROM orders WHERE created_at > now() - interval '7 days';
SELECT user_id, count(*) FROM recent_orders GROUP BY user_id; -- OK
If a CREATE TABLE or CREATE VIEW can't be parsed, the "table not found" errors for it later in the file say so.
SET search_path TO analytics, public (also SET LOCAL search_path, SET search_path = ...) is followed for the rest of the file: unqualified names are looked up in the listed schemas, in order, and tables created without a schema go in the first one. SET search_path TO DEFAULT goes back to the default schema.
psql scripts
With the PostgreSQL dialect, files written for psql are accepted:
- Backslash meta-commands (
\set,\i,\connect,\if, …) are skipped. \g,\gsetand\gxend a query like;.:varand:'var'interpolations are treated as untyped placeholders, and:"var"as an identifier whose name sqlsift can't know, so no "not found" diagnostic is reported for it.- The data of a
COPY ... FROM stdin;(the lines up to\., as inpg_dumpoutput and seed files) is skipped, in query files and schema files.
MySQL syntax
With the MySQL dialect, these are accepted in schema and query files:
- Versioned comments (
/*!50001 ... */, as written bymysqldump) are read as SQL, the way MySQL runs them. Views in a dump are loaded. ALGORITHM = ...,DEFINER = ...andSQL SECURITY ...inCREATE VIEWare ignored.INSERT ... SET col = value, ...is checked likeINSERT ... (col, ...) VALUES (value, ...), includingON DUPLICATE KEY UPDATE.- Index hints (
USE,FORCEandIGNORE INDEX/KEY),STRAIGHT_JOINand the SELECT modifiers (SQL_CALC_FOUND_ROWS,SQL_NO_CACHE,HIGH_PRIORITY,DISTINCTROW, ...) are ignored.
dbt and Jinja templates
dbt models are Jinja templates, not plain SQL. With --templating jinja (or templating = "jinja" in sqlsift.toml) sqlsift masks the template syntax before checking a query file. This is turned on automatically when a dbt_project.yml is in the current directory, in the directory of sqlsift.toml, or in a directory above the query file (so sqlsift check -s schema.sql 'analytics/models/**/*.sql' works from a monorepo root); set templating = "none" to turn it off. When a file with {{ or {% fails to parse without templating, the error suggests --templating jinja.
{{ config(materialized='incremental') }}
select c.id, c.frist_name, o.amount -- E0002: 'frist_name' is checked against customers
from customers c
join {{ ref('stg_orders') }} o on o.customer_id = c.id -- o.amount: not reported
{% if is_incremental() %}
where c.id > (select max(customer_id) from {{ this }})
{% endif %}
{# comments #}and{% statements %}are skipped. The SQL inside a{% for %}block is checked once, as a single iteration (bothloop.firstandloop.last): separators such as{% if not loop.last %},{% endif %}and{{ ',' if not loop.last }}are dropped.- Of
{% if %} ... {% elif %} ... {% else %} ... {% endif %}only one branch is checked; the others are skipped. It is the first branch whose condition may be true: sqlsift evaluatesis_incremental()(false, as on a model's first build),var('x', true)/var('x', false)(the default),target.type == 'snowflake'/!=/in [...](against the dialect's dbt adapter),{% set x = ... %}variables holding such a value, andnot/and/orof these. Any other condition counts as true. - The bodies of
{% set x %}...{% endset %},{% call %}...{% endcall %},{% macro %}...{% endmacro %}(andtest,materialization,docsblocks) are skipped. The body of{% raw %}...{% endraw %}is checked as SQL. {{ source('raw', 'customers') }}is the schema's tableraw.customerswhen your schema has it, so its columns are checked.{{ ref('orders') }}and{{ source('shop', 'orders') }}have the columns of the model or source in dbt'scatalog.json, when there is one (see Columns of models and sources below).- Any other
{{ ref(...) }}or{{ source(...) }},{{ this }}and any other{{ ... }}where a table name is expected (afterFROM,JOIN,INTO,UPDATE,USING) is a table whose columns sqlsift doesn't know: it is not reported as missing, and neither are columns qualified by it or unqualified columns that may come from it. Put the tables your models read from (the dbt sources) in the schema, or generate dbt's catalog, to get them checked. {{ ... }}as part of a name (total_{{ c }},as {{ alias }}) is a name sqlsift doesn't know: an output column named by it has an unknown name, and a table alias built from it in a loop (left join o as {{ f }}_o) makes qualifiers of that shape (a_o.v) unknown.{{ ... }}at the start of a statement, after any comments ({{ config(...) }}), is skipped. When a query follows it directly, the macro may write the query'sWITHclause, so table names in that query that aren't in the schema are not reported.{{ ... }}right after a complete expression is a macro that adds list items or a whole clause. In aSELECTlist (select id {{ fivetran_utils.apply_source_relation() }} from t) it adds columns sqlsift doesn't know: the columns of that query are unknown to the queries that read it. Elsewhere ({{ dbt_utils.group_by(2) }},partition by id {{ ... }}, a macro that writes aWHEREclause) it is skipped.{{ ... }}that is the whole body of a CTE or a subquery inFROM(with spine as ({{ dbt_utils.date_spine(...) }})) is a query whose columns sqlsift doesn't know.{{ var('x') }}where a table name is expected is the relation of the dbt project variablexwhenvars:indbt_project.ymlsets it to a{{ ref(...) }}or{{ source(...) }}that sqlsift knows (diagnostics name itvar.x), and{{ var('x', ref('m')) }}is the default relation; otherwise it is a table with unknown columns.{{ ... }}as a type (x::{{ dbt.type_bigint() }},cast(x as {{ ... }})) is a type sqlsift doesn't know.- Any other
{{ ... }}, and a string literal with one in it ('{{ var("start") }}'), is an untyped value, like a bind parameter: it is never a type mismatch. sqlsift:disabledirectives work in Jinja comments too:{# sqlsift:disable-file #},{# sqlsift:disable E0002 #}(dbt users avoid--comments, which end up in the compiled SQL).- Everything else is checked as usual, and diagnostics point at the original file.
Macros that expand to other SQL can't be followed: a parse error on one shows the template tag (found: Jinja expression {{ dbt_utils.date_spine(...) }}). Use ignore or {# sqlsift:disable-file #} for such files.
Columns of models and sources from dbt's catalog (alpha)
dbt docs generate writes target/catalog.json, with the columns and types of every model, seed, snapshot and source as they are in the warehouse. When it exists in the dbt project (next to dbt_project.yml), sqlsift reads it, so the columns of {{ ref(...) }} and {{ source(...) }} are checked too, and schema files become optional:
$ dbt docs generate
$ sqlsift check 'models/**/*.sql'
error[E0002]: Column 'amount_usd' not found in table 'ref.stg_orders'
--> models/marts/order_typo.sql:3:22
Use --dbt-catalog <PATH> (or dbt_catalog = "..." in sqlsift.toml) when the catalog is elsewhere, for example when target-path is changed or the catalog is downloaded from a CI artifact or dbt Cloud. It is read whatever the templating, and a configured catalog that is missing or not a dbt catalog is an error; one found in target/ that can't be read is a warning.
{{ ref('orders') }}is the model, seed or snapshot namedorders; diagnostics name itref.orders. Its relation is also a table under its warehouse name (analytics.orders) for SQL that names it directly. Tables in your schema files take precedence over the catalog.{{ source('shop', 'orders') }}is the tableordersof the sourceshop.- A
ref()of a model that isn't in the catalog (not built yet when the catalog was generated), a package-qualifiedref('package', 'model'), a versionedref('model', v=2), and a model name that two packages use, are tables with unknown columns, as without a catalog. - Column types are the warehouse's type names. The common ones (
integer,int64,number,varchar,string,timestamp_ntz,boolean, ...) are checked; others (variant,struct, arrays, geography types) are unknown types and never a type mismatch.
The catalog is a snapshot of the warehouse when dbt docs generate last ran: regenerate it after changing a model's columns, or sqlsift checks the models that use it against the old columns. The language server reads it when it starts. This support is alpha: how ref() names appear in diagnostics and how the catalog is found may change.
With Jinja templating, files in the dbt project's macros/, dbt_packages/ and target/ directories are skipped when they are matched by a glob pattern ('**/*.sql'); a file named on its own is still checked. Add other directories you don't want checked (analyses/, snapshots/) to ignore.
Set dialect to your warehouse. Snowflake, BigQuery, Redshift and Databricks are supported in alpha (see Dialects): their queries parse and names are checked, but many warehouse types and functions are not known yet, and are left unreported.
sqlc query files
sqlc query files are plain SQL with a -- name: comment before each query, so they can be checked as they are. Diagnostics name the query they are in:
-- name: ListPosts :many
SELECT id, titel FROM posts WHERE author_id = $1;
error[E0002]: Column 'titel' not found in table 'posts'
--> queries/posts.sql:2:12
|
2 | SELECT id, titel FROM posts WHERE author_id = $1;
| ^^^^^
= note: in query 'ListPosts'
= help: Did you mean 'title'?
A statement belongs to the last -- name: <Name> :<command> comment before it. The name is the query_name field in JSON output, a logical location in SARIF output, and is added to the message in SARIF, github and editor diagnostics.
sqlc's named parameters are untyped placeholders, like $1:
-- name: ListPosts :many
SELECT id, title FROM posts
WHERE id > @after_id AND author_id = sqlc.arg(author_id)
AND (title = sqlc.narg('title') OR sqlc.narg('title') IS NULL)
LIMIT @page_size;
-- name: GetPostsByIDs :many
SELECT id, title FROM posts WHERE id IN (sqlc.slice(ids));
sqlc.arg(name),sqlc.narg(name)andsqlc.slice(name)(with the name bare or quoted) are placeholders in every dialect.@nameis a placeholder with the PostgreSQL dialect only. PostgreSQL's@operators are left alone:@>,<@,@@, and@followed by a space (absolute value). With MySQL,@namestays a user variable (SET @x = 1), and with SQLite a bind parameter; sqlc supports@namefor neither, so usesqlc.arg(name)there.- Parameters in string literals, quoted identifiers and comments are left alone.
sqlx query files
The files sqlx's query_file! and query_file_as! read are plain SQL with $1 parameters (? with MySQL and SQLite), one statement each, so they are checked as they are, against the migrations sqlx::migrate!() applies:
schema_dir = "migrations" # <timestamp>_name.sql; *.down.sql halves are skipped
files = ["queries/**/*.sql"]
sqlx's type overrides in column aliases (id AS "id!", status AS "status: Status") are ordinary quoted aliases. SQL written inline in Rust (query!("SELECT ...")) is not checked. See the sqlx example project.
aiosql and HugSQL query files
aiosql (Python) and HugSQL (Clojure) load named queries from .sql files. They are checked as they are, and diagnostics name the query they are in, as for sqlc:
-- name: get-user-by-id^
select id, nmae from users where id = :id;
error[E0002]: Column 'nmae' not found in table 'users'
--> queries/users.sql:2:12
|
2 | select id, nmae from users where id = :id;
| ^^^^
= note: in query 'get-user-by-id'
= help: Did you mean 'name'?
The name comments are recognised in every dialect:
- aiosql:
-- name: <name>with an optional parameter list and operation suffix:-- name: get-user-by-id^,-- name: list_posts(author), and$,!,<!,*!,#. The name is shown as written (get-user-by-id, not the Python method nameget_user_by_id). - HugSQL:
-- :name <name> :<command> :<result>(-- :name get-user :? :1),-- :name-and-- :snip <name>.
A name comment starts a new statement, so queries need not end with ;. In a file with such comments, parameters are untyped placeholders, like $1:
| Parameter | Becomes |
|---|---|
aiosql :id, :user.id; HugSQL :id, :user-id, :v:id, :v*:ids, :t:pair | a placeholder (($1) right after IN) |
HugSQL VALUES :t*:rows | rows of unknown columns, so the column count isn't checked |
HugSQL :i:col, :i*:cols, :identifier:tbl | a name sqlsift doesn't know: it is never reported, and columns that may come from it aren't either |
HugSQL :sql:x, :snip:x, :snip*:x, :frag:x | nothing; the query may have clauses and relations its text doesn't show, so names it can't resolve aren't reported |
HugSQL snippet bodies (after -- :snip) and queries with a Clojure expression (--~ ... or /*~ ... ~*/) are skipped: their SQL is only known at run time. :: casts (:id::int), :=, array slices (a[i:j]) and anything in string literals, quoted identifiers and comments are left alone. Without name comments, :name is a psql variable with the PostgreSQL dialect (see psql scripts) and a bind parameter with MySQL and SQLite.
The aiosql example is a complete project.
SQL in TypeScript and JavaScript
Files ending in .ts, .tsx, .js, .jsx, .mts, .cts, .mjs or .cjs are checked for SQL in tagged template literals, as used by postgres.js, Slonik, kysely, @vercel/postgres, Prisma and others. In Vue (.vue) and Svelte (.svelte) components, the <script> blocks are checked the same way:
const posts = await sql`
SELECT id, titel FROM posts WHERE author_id = ${authorId}
`;
sqlsift check -s schema.sql 'src/**/*.ts'
Diagnostics point at the query's line and column in the source file. When a glob pattern matches TypeScript or JavaScript files in node_modules, dist, build, .next, .nuxt or .svelte-kit directories (installed packages and build output), they are skipped; name such a file, or start the pattern in such a directory ('dist/**/*.js'), to check it anyway.
Tags
Which templates are SQL is decided by their tag: embedded_sql_tags in sqlsift.toml lists the tags (default ["sql"]). The tag expression may be a chain of member accesses and calls, and matches when its last or its first identifier is one of the tags:
| Tag expression | Matches "sql" because of |
|---|---|
sql`...`, db.sql`...`, Prisma.sql`...` | the last identifier |
sql.unsafe`...`, sql.type(schema)`...`, sql.typeAlias('id')`...` (Slonik) | the first identifier |
Type arguments are skipped (sql<boolean>`...`, prisma.$queryRaw<User[]>`...`). Tags per library:
| Library | embedded_sql_tags |
|---|---|
postgres.js, Slonik, kysely, @vercel/postgres, sql-template-strings | ["sql"] (the default) |
| Prisma | ["$queryRaw", "$executeRaw"] (and "sql" for Prisma.sql fragments) |
embedded_sql_tags = ["sql", "$queryRaw", "$executeRaw"]
Statements and fragments
A template is checked only when it starts with a statement keyword (SELECT, WITH, INSERT, UPDATE, DELETE, VALUES, CREATE, ALTER, DROP, TRUNCATE, MERGE, ..., after comments and opening parentheses). Other templates written with the same tag are query fragments, such as kysely's sql`published = ${x}`, Prisma.sql`WHERE id > ${minId}` or sql`AND published`, and are skipped. (A misspelled statement keyword, as in sql`SELEC id FROM posts`, is still checked and reported as a syntax error.)
Each checked template is one statement:
${expr}is an untyped placeholder, like$1(?for MySQL and SQLite), or a parenthesized list afterIN.${expr}where a table name is expected (afterFROM,JOIN,INTO,UPDATEorTABLE) is a name sqlsift can't know, so no "not found" diagnostic is reported for it, as for psql's:"var".${expr}after a value or a name is a fragment between clauses, and is left out:WHERE a = ${a} ${cond ? sql`AND b` : sql} ORDER BY id, or Prisma'sSELECT id FROM users ${where} ``.- postgres.js helpers are rows and columns sqlsift can't know:
INSERT INTO users ${sql(user, 'name')},INSERT INTO posts (a, b) VALUES ${sql(rows)}andUPDATE users SET ${sql(patch)} WHERE ...are checked without their column lists. - Templates inside another SQL template's
${...}are fragments and are not checked on their own. - SQL built by string concatenation, and untagged templates, are not checked.
--stdin-filename with a TypeScript, JavaScript, Vue or Svelte extension checks stdin the same way.
Directives
Suppression comments work as code comments (// sqlsift:disable-file, /* sqlsift:disable E0002 */) and as SQL comments inside a template. A sqlsift:disable comment on a line of its own applies to the next line of SQL (for a template starting on the next line, its first line of SQL); after a query, it applies to that line. A block comment followed by code on the same line is ignored. A sqlsift:disable-file comment applies to the whole file, also when it is written inside one of its templates.
Exit codes
| Code | Meaning |
|---|---|
0 | No errors (warnings may have been reported) |
1 | At least one error, or more warnings than --max-warnings / max_warnings |
2 | Usage or configuration error: missing files, a pattern that matches no files, an invalid sqlsift.toml, … |