Rules¶
sqlty check reports diagnostics as file:line: [rule] message. Each rule
has a default severity, changed per rule in [tool.sqlty.rules]:
error makes sqlty check exit with 1, warn prints without failing, and
ignore turns the rule off. For one run, --error RULE, --warn RULE and
--ignore RULE override the table; see CLI.
| Rule | Group | Default | Fixable | Reports |
|---|---|---|---|---|
column-count-mismatch |
Row type | error | no | Row type has a different number of fields than the query has columns |
column-type-mismatch |
Row type | error | no | Row type field can't hold its column's values |
column-name-mismatch |
Row type | warn | no | NamedTuple field name differs from its column name |
nullable-mismatch |
Row type | error | no | Row type field is not optional but its column can be NULL |
json-shape-mismatch |
Row type | error | no | Row type field can't hold the JSON its column builds |
redundant-optional |
Row type | ignore | no | Row type field is optional but its column cannot be NULL |
literal-mismatch |
Row type | error | no | Row type field is a Literal that does not cover its column's values |
redundant-literal |
Row type | ignore | no | Row type field is a Literal that lists values its column never takes |
typeddict-key-mismatch |
Row type | error | no | Columns don't fit the fields of a TypedDict, dataclass or Pydantic model |
typeddict-inheritance |
Row type | error | no | TypedDict subclass breaks a closed or extra_items base (PEP 728) |
select-star-no-schema |
Query | error | no | SELECT * with no schema to expand it |
select-star |
Query | warn | yes | SELECT * |
inline-sql-comment |
Query | warn | yes | "--sql SELECT ...": the query is a SQL comment |
mysql-sql-marker |
Query | warn | yes | --sql marker with dialect = "mysql", which MySQL rejects |
unparsed-query |
Query | warn | no | Query that does not parse, so sqlty cannot check it |
unknown-table |
Names | error | no | Table the schema does not define |
unknown-column |
Names | error | no | Column the table does not have |
ambiguous-column |
Names | error | no | Unqualified column more than one table has |
unknown-function |
Names | warn | no | Function whose result sqlty cannot type |
unknown-type |
Names | warn | no | Cast or column type without a Python type |
unsupported-syntax |
Names | warn | no | Construct that parses but is not typed |
insert-missing-column |
Names | error | no | INSERT that leaves out a NOT NULL column without a default |
insert-value-count |
Names | error | no | INSERT that gives a different number of values than the columns it writes |
ungrouped-column |
Names | error | no | Grouped query selects a column it neither groups by nor aggregates |
stale-rewrite |
Rewrite | warn | yes | --sql string not wrapped in SQL[T](...) |
stale-type |
Rewrite | warn | yes | SQL[tuple[...]] wider than the inferred row type |
narrow-type |
Rewrite | error | yes | SQL[tuple[...]] doesn't admit every row the query returns |
fstring-skipped |
Rewrite | warn | no | f-string with --sql cannot be rewritten |
tstring-skipped |
Rewrite | warn | no | t-string with --sql and a field that changes the SQL |
unchecked-query |
Rewrite | warn | no | --sql query that is not plain string literals |
dynamic-query |
Rewrite | ignore | no | Helper query that is not a string literal |
untyped-query |
Rewrite | warn | yes | SQL[UNTYPED] query |
param-count-mismatch |
Params | error | no | Call passes a different number of parameters than its query has |
param-type-mismatch |
Params | error | no | Call passes an argument the query's parameter can't take |
params-type-mismatch |
Params | error | no | P of SQL[R, P] doesn't fit the query's parameters |
unused-ignore |
Suppression | warn | no | # sqlty: ignore comment that suppresses nothing |
nullable-mismatch and redundant-optional check nullability; see
Nullability. literal-mismatch and
redundant-literal check Literal annotations against the values a column can
take; see Literal values.
sqlty check --fix applies the fixable rules; sqlty rule NAME prints a
rule's page.
Inline suppression¶
A comment on the line a diagnostic is reported on drops it:
# sqlty: ignoredrops every diagnostic on that line.# sqlty: ignore[rule-a, rule-b]drops only the listed rules. A name that matches no diagnostic on the line drops nothing, so the diagnostic stays.
The comment is read with Python's tokenizer, so a # inside a string does
not start one. For a statement that spans lines, put the comment on the line
the diagnostic names. sqlty check --fix keeps the comment and does not apply
a fix for a rule the comment suppresses.
from typing import TypedDict
from sqlty import SQL
class User(TypedDict):
id: str
# error:
q = SQL[User]("--sql\nSELECT id::INTEGER FROM users") # sqlty: ignore[column-type-mismatch]
Enable unused-ignore to report comments that suppress
nothing, so accepted cases do not outlive their reason.
The examples below show what sqlty check reports for each block. # error:
lines sit above the line they report on, warnings included; a bare # error:
means nothing is reported.
Row type rules¶
These rules compare a row type (TypedDict,
NamedTuple, dataclass, Pydantic model) defined in the same file with the
query's columns.
column-count-mismatch¶
Default: error. The row type has more or fewer fields than the query returns columns. Checked before types and names.
from typing import TypedDict
from sqlty import SQL
class User(TypedDict):
id: int
# error: [column-count-mismatch] "User has 1 fields but query returns 2 columns"
q = SQL[User]("--sql\nSELECT id::INTEGER, name::TEXT FROM users")
Fix: add or remove fields, or change the query.
column-type-mismatch¶
Default: error. A field can't hold the values of its column's inferred type.
Also reported for extra columns that don't fit a TypedDict's extra_items
type. A field typed Any, a union containing Any or the column type, or
float for an int column is accepted, as is a NewType wherever its
supertype is and a parametrised generic (list[str], dict[str, int]) for a
column that infers list or dict (element types are not compared; a JSON
column infers Any, which any field accepts); a narrower field, such as bool for
an int column, is not; see sqlty rule column-type-mismatch.
from typing import TypedDict
from sqlty import SQL
class User(TypedDict):
id: str
name: str
# error: [column-type-mismatch] "User.id is str but query column 'id' infers int"
q = SQL[User]("--sql\nSELECT id::INTEGER, name::TEXT FROM users")
Fix: change the field type or the cast.
column-name-mismatch¶
Default: warn. A NamedTuple field name differs from its column name or
alias. NamedTuple fields are matched by position, so the row would still
be built, with misleading names. TypedDicts, dataclasses and Pydantic models
are matched by name instead; see typeddict-key-mismatch.
from typing import NamedTuple
from sqlty import SQL
class User(NamedTuple):
user_id: int
name: str
# error: [column-name-mismatch] "User.user_id doesn't match query column 'id'"
q = SQL[User]("--sql\nSELECT id::INTEGER, name::TEXT FROM users")
Fix: rename the field or alias the column (id AS user_id).
nullable-mismatch¶
Default: error. A row type field is not
optional, but its query column can be NULL: a schema column without NOT NULL,
a column of the optional side of an outer join, an aggregate over an empty
input. Fields typed Any and columns of unknown nullability are not reported;
see sqlty rule nullable-mismatch.
schema.sql:
from typing import TypedDict
from sqlty import SQL
class User(TypedDict):
id: int
name: str
# error: [nullable-mismatch] "User.name is str but query column 'name' can be NULL"
q = SQL[User]("--sql\nSELECT id, name FROM users")
Fix: annotate name: str | None, or filter or COALESCE the NULLs away.
json-shape-mismatch¶
Default: error. A column built with jsonb_build_object(), jsonb_agg(), to_jsonb() and
the like has a known JSON shape, which the row type field must be able to hold: nested
TypedDicts, list[...] and dict[str, ...] are followed. Scalars of the wrong type,
members that can be null, required keys the query may omit and undeclared keys are
reported with their path. Columns of unknown shape and fields typed Any are not
reported; see JSON shapes and sqlty rule json-shape-mismatch.
schema.sql:
from typing import TypedDict
from sqlty import SQL
class Doc(TypedDict):
id: int
total: str
class Order(TypedDict):
doc: Doc
# error: [json-shape-mismatch] "Order.doc is Doc but query column 'doc' builds JSON that does not fit: doc.total is str but the query builds a number"
q = SQL[Order]("--sql\nSELECT jsonb_build_object('id', id, 'total', total) AS doc FROM orders")
Fix: annotate total: float, or cast the value in the query (total::TEXT).
redundant-optional¶
Default: ignore. The strict side of nullable-mismatch: a field is annotated
T | None or Optional[T], but its column cannot be NULL. A wider annotation
is correct, only looser than needed, so the rule stays off until enabled in
[tool.sqlty.rules]; see sqlty rule redundant-optional.
schema.sql:
from typing import TypedDict
from sqlty import SQL
class User(TypedDict):
id: int
name: str | None
# error: [redundant-optional] "User.name is str | None but query column 'name' is never NULL"
q = SQL[User]("--sql\nSELECT id, name FROM users")
Fix: drop the | None.
literal-mismatch¶
Default: error. A row type field is a Literal that does not cover every
value its query column can take: the values of an enum, of a CHECK (col IN
(...)) constraint, of a CASE over constants and the like. Fields typed str,
int, Any or an enum class take any value and are not reported; see sqlty
rule literal-mismatch.
schema.sql:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'refunded'))
);
from typing import Literal, TypedDict
from sqlty import SQL
class Order(TypedDict):
id: int
status: Literal["pending", "paid"]
# error: [literal-mismatch] "Order.status is Literal['pending', 'paid'] but query column 'status' can be 'refunded'"
q = SQL[Order]("--sql\nSELECT id, status FROM orders")
Fix: add the missing values to the Literal, or annotate str.
redundant-literal¶
Default: ignore. The strict side of literal-mismatch: the Literal lists
values the column can never take. A wider Literal is correct, only looser than
needed (one alias often serves many queries), so the rule stays off until enabled
in [tool.sqlty.rules]; see sqlty rule redundant-literal.
schema.sql:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid'))
);
from typing import Literal, TypedDict
from sqlty import SQL
class Order(TypedDict):
id: int
status: Literal["pending", "paid", "refunded"]
# error: [redundant-literal] "Order.status is Literal['pending', 'paid', 'refunded'] but query column 'status' is never 'refunded'"
q = SQL[Order]("--sql\nSELECT id, status FROM orders")
Fix: drop the extra values.
typeddict-key-mismatch¶
Default: error. TypedDicts, dataclasses and Pydantic models are matched to
columns by name. The query has a column with no field (a Pydantic model
ignores it, and extra_items admits it), or lacks a required field. A
field with a default, or a NotRequired key, may be missing.
from typing import TypedDict
from sqlty import SQL
class User(TypedDict, closed=True):
id: int
name: str
# error: [typeddict-key-mismatch] "User has no key for query column 'email'"
# error: [typeddict-key-mismatch] "User.name is required but missing from query"
q = SQL[User]("--sql\nSELECT id::INTEGER, email::TEXT FROM users")
Fix: add the key (as NotRequired if it may be missing), allow
extra_items, or change the query. See
Closed TypedDicts.
typeddict-inheritance¶
Default: error. A TypedDict subclass adds keys to a closed base, reopens it,
or changes a base's extra_items in a way PEP 728 forbids. Reported on the
subclass's class line.
from typing import TypedDict
from sqlty import SQL
class Base(TypedDict, closed=True):
id: int
# error: [typeddict-inheritance] "User cannot add key 'name' to closed base Base"
class User(Base):
name: str
q = SQL[User]("--sql\nSELECT id::INTEGER, name::TEXT FROM users")
Fix: see TypedDict inheritance for what a subclass may do.
Parameter rules¶
Both rules read the arguments a query helper call passes for the query's placeholders; see Query parameters.
param-type-mismatch¶
Default: error. An argument whose type, stated in the source, can't be the type
the query gives its parameter, or None for a parameter stored in a NOT NULL
column. A str, a number for a numeric parameter, an argument of a cast
parameter and any argument of unknown type are never reported. Not checked for
SQLite, except None. The fields of a --sql t-string are arguments too.
from typing import Annotated, LiteralString
from sqlty import SQL
def execute(query: Annotated[LiteralString, SQL], params: tuple[object, ...]) -> None: ...
# error: [param-type-mismatch] "parameter 1 expects int, got bytes"
execute("SELECT 1 FROM users LIMIT %s", (b"1",))
Fix: pass a value of the expected type.
param-count-mismatch¶
Default: error. A different number of positional arguments than the query has placeholders, or named arguments that don't match the query's names.
from typing import Annotated, LiteralString
from sqlty import SQL
def execute(query: Annotated[LiteralString, SQL], params: tuple[object, ...]) -> None: ...
# error: [param-count-mismatch] "query has 2 parameters, the call passes 1"
execute("SELECT 1 FROM users WHERE id = %s AND name = %s", (1,))
Fix: pass one argument per placeholder.
params-type-mismatch¶
Default: error. The parameter type P of SQL[R, P] doesn't fit the query: a
tuple with a different number of items than placeholders, a TypedDict whose
keys differ from the names, or an item whose type the parameter can't take,
judged as param-type-mismatch judges an argument. See
Typed parameters.
from sqlty import SQL
# error: [params-type-mismatch] "tuple[int, int] has 2 items but the query has 1 parameter"
q = SQL[tuple[int], tuple[int, int]]("--sql\nSELECT 1 FROM users LIMIT %s")
Fix: change P, or let sqlty rewrite write it with param-types = true.
Suppression rules¶
unused-ignore¶
Default: warn. A # sqlty: ignore comment that suppresses no diagnostic on
its line, or a rule listed in one that suppresses nothing. It reports, for
example:
Fix: remove the comment, or correct the rule name.
Query rules¶
select-star-no-schema¶
Default: error. SELECT * without a schema: sqlty cannot know the columns.
# error: [select-star-no-schema] "SELECT * used but no schema available"
q = SQL[tuple[int]]("--sql\nSELECT * FROM users")
Fix: list the columns, or configure a
schema. The annotation is not compared with an inferred type, and check --fix does not change it.
select-star¶
Default: warn. * or t.* with a schema, in the SELECT list, in
RETURNING, or in a CTE the query reads. The row type follows the table, so
any change to the table changes it.
schema.sql:
from sqlty import SQL
# error: [select-star] "SELECT * used — expand to explicit column list"
q = SQL[tuple[int, str]]("--sql\nSELECT * FROM users")
Fix: sqlty check --fix replaces * with the cast column list from the
schema, unless the string holds escapes or quotes, the query is split over
several concatenated strings, or a bare * reads from more than one table
(JOIN or FROM a, b). A qualified u.* keeps its qualifier
(u.id::INT, ...), so it is expanded in a join too. A * the schema
cannot resolve (a USING or NATURAL join) gets no inferred type, so its
annotation is kept. A * over a subquery or CTE is typed from that query's
columns but not expanded by --fix.
inline-sql-comment¶
Default: warn. --sql is followed by a space instead of a newline, so the
whole query is a SQL comment and the string is never wrapped.
# error: [inline-sql-comment] ""--sql SELECT ..." treats the query as a SQL comment; use "--sql\nSELECT ..." or run sqlty check --fix"
q = "--sql SELECT id::INTEGER FROM users"
Only a statement after the space is reported: SELECT with a column,
WITH ... AS (, INSERT INTO, UPDATE t SET, DELETE FROM, VALUES (,
DDL such as CREATE TABLE, TRUNCATE, MERGE INTO or EXPLAIN. Text such as
"--sql flag enables it" is not.
Fix: sqlty check --fix replaces the space after --sql with \n in plain
string literals (not raw, f- or t-strings) that this rule reports;
sqlty rewrite never changes string contents.
mysql-sql-marker¶
Default: warn. With dialect = "mysql", a plain string literal opens with the
--sql marker. MySQL and MariaDB read -- as a comment only when whitespace follows
it, so the server rejects the query with a syntax error (1064). -- sql is a
comment in every dialect and sqlty reads it as the marker too.
from sqlty import SQL
# error: [mysql-sql-marker] "MySQL reads "--sql" as an expression, not a comment; use "-- sql""
q = SQL[tuple[int]]("--sql\nSELECT CAST(id AS SIGNED) FROM users")
Fix: sqlty check --fix replaces --sql with -- sql in plain string
literals, wrapped or not.
unparsed-query¶
Default: warn. A query that does not parse in the configured dialect: a
--sql string, the argument of SQL[T](...), or a string passed to an
Annotated[LiteralString, SQL] parameter. The message quotes sqlparser's
error.
from sqlty import SQL
# error: [unparsed-query] "found: DOCUMENT"
q = SQL[tuple[str]]("--sql\nSELECT NULL::xml IS DOCUMENT")
Fix: rewrite the query in a form the parser reads. sqlty check --statistics
counts such queries as unparsed whether or not the rule is enabled.
Name rules¶
The first six rules explain why a result column is untyped: with all of them
enabled and a schema configured, no column is left untyped without a diagnostic.
insert-missing-column and insert-value-count check what an INSERT writes,
and ungrouped-column what a grouped query selects. The rules for SQL the
database rejects (unknown tables and columns, ambiguous columns, an INSERT
missing a column or a value, an ungrouped column) are errors; those for valid SQL sqlty cannot type are warnings. The
name diagnostics guide shows how to change them. All but unsupported-syntax are only judged when a
schema is configured, because without one every
table, column, function and type is unknown. Tables, columns and ambiguity are
checked only in the postgres, mysql and sqlite dialects.
unknown-table¶
Default: error. A FROM, JOIN or DML target that the schema does not
define, or a qualifier (x.id) that names no table or alias of the query.
CTEs, derived tables, views, table functions and system catalogs are not
reported.
unknown-column¶
Default: error. A column that its table does not have, in the select list,
WHERE, JOIN ... ON, GROUP BY, HAVING, ORDER BY and what INSERT,
UPDATE and DELETE write.
ambiguous-column¶
Default: error. An unqualified column that more than one table of the query
has. Columns merged by USING or NATURAL are not ambiguous.
unknown-function¶
Default: warn. A function call sqlty cannot type because it does not know the function.
unknown-type¶
Default: warn. A cast target or schema column type with no Python type.
unsupported-syntax¶
Default: warn. A construct that parses but that sqlty does not type: an
expression without a typing rule, VALUES outside PostgreSQL, MySQL and SQLite,
* over a USING join, a column of a table function whose columns sqlty does
not read. NULL and placeholders
are not reported.
insert-missing-column¶
Default: error. An INSERT that leaves out a schema column declared NOT NULL
without a default. A DEFAULT, serial, identity, generated or auto-increment
column, and SQLite's INTEGER PRIMARY KEY, get a value anyway. Without a
column list, the values fill the table's columns in order, and PostgreSQL gives
the columns past them their defaults.
insert-value-count¶
Default: error. An INSERT whose VALUES rows or query have a different
number of values than the columns it writes. Without a column list, MySQL and
SQLite want a value for every column, and PostgreSQL at most one.
ungrouped-column¶
Default: error. A grouped query (GROUP BY, or an aggregate in the select
list) that selects a column it neither groups by nor aggregates, which
PostgreSQL rejects. Grouping by a table's primary key covers its other columns.
Rewrite rules¶
stale-rewrite¶
Default: warn. A --sql string that rewrite would wrap is not wrapped in
SQL[T](...). The marker may follow whitespace or a line break, as in a
triple-quoted string that opens on its own line; implicitly concatenated
strings are one query.
# error: [stale-rewrite] "Source has --sql strings that aren't wrapped in SQL[T]"
q = "--sql\nSELECT id::INTEGER FROM users"
Fix: sqlty rewrite or sqlty check --fix. Only strings rewrite would wrap
are reported: statements without rows, uninferable queries in skip mode,
the "--sql SELECT" form, and strings that are not plain literals
(f-, t-, raw or bytes strings, alone or in a concatenation) are not.
stale-type¶
Default: warn. The T in SQL[tuple[...]] admits every row the query returns
but differs from the type sqlty infers now, for example after a column was made
NOT NULL. The code still works; it handles values that never come. An item
that only parametrises an inferred bare generic (list[str] for list) is not
stale. Named row types are covered by the row type rules instead.
from sqlty import SQL
# error: [stale-type] "SQL[tuple[int | None]] should be SQL[tuple[int]]"
q = SQL[tuple[int | None]]("--sql\nSELECT id::INTEGER FROM users")
Fix: sqlty check --fix writes the inferred T, keeping parametrised
generics; see also
Editing queries.
narrow-type¶
Default: error. The SQL type is wider than the T in SQL[tuple[...]]: the
query returns rows the annotation doesn't admit, for example after a column was
made nullable, added to the query, or retyped. The code gets a None, a value or
a column it doesn't expect, and the type checker can't tell. A single value such
as SQL[int] is flagged too, since a row is never one. Named row types are
covered by the row type rules instead.
from sqlty import SQL
# error: [narrow-type] "SQL[tuple[int]] should be SQL[tuple[int, str]]"
q = SQL[tuple[int]]("--sql\nSELECT id::INTEGER, name::TEXT FROM users")
# error: [narrow-type] "SQL[int] should be SQL[tuple[int]]"
count = SQL[int]("--sql\nSELECT count(*)::INTEGER FROM users")
Fix: sqlty check --fix writes the inferred T; your type checker then finds
the code that needs to handle it.
fstring-skipped¶
Default: warn. An f-string starting with --sql is skipped by the rewriter,
because its content is only known at runtime:
# error: [fstring-skipped] "f-string with --sql prefix skipped (f-strings cannot be statically rewritten)"
q = f"--sql\nSELECT {column}::INTEGER FROM users"
Fix: use query parameters instead of interpolation.
tstring-skipped¶
Default: warn. A t-string (PEP 750) with
--sql and a field that changes the SQL in a way sqlty can't follow: a
conversion, a format spec other than psycopg's, or, in a module that uses
psycopg, a {name:i} identifier in a query that returns rows. A t-string whose
fields sqlty can read is rewritten as SQLTemplate[T](t"...") instead; see
t-strings.
user_id = 1
# error: [tstring-skipped] "t-string with --sql prefix skipped (the field {user_id!r} changes the SQL)"
q = t"--sql\nSELECT id::INTEGER FROM users WHERE id = {user_id!r}"
A concatenation that holds an f- or t-string anywhere is reported the same way.
unchecked-query¶
Default: warn. A --sql query that is not plain string literals: a raw or
bytes string (alone or in a concatenation), or a SQL[T](...) argument with
anything but string literals. sqlty neither checks nor rewrites it.
from sqlty import SQL
# error: [unchecked-query] "--sql query is not plain string literals, so it is not checked or rewritten"
q = SQL[tuple[int]]("--sql\nSELECT id::INTEGER FROM " + table)
Fix: write the query as one plain literal, or implicitly concatenated ones.
SQL[Any](...), or Any as the row type of a helper called with a psycopg
composition, opts out explicitly.
dynamic-query¶
Default: ignore. A helper call whose query is not a string literal: a variable, a
constant, a call or a concatenation. sqlty does not check it. A helper's own query
parameter, forwarded to another helper, is not reported. With preview, a query built
from literals is checked along every path instead (Query variants),
and one with more variants than max-query-variants is reported here.
from typing import Annotated, LiteralString
from sqlty import SQL
def fetch_all[T](query: Annotated[LiteralString, SQL], form: type[T], /) -> list[T]: ...
NAMES = "SELECT name FROM users"
# error: [dynamic-query] "query is not a string literal, so it is not checked"
names = fetch_all(NAMES, str)
Fix: pass the query as a string literal. sqlty check --statistics counts these calls
under dynamic-query.
untyped-query¶
Default: warn. A query is wrapped as SQL[UNTYPED](...)
(Untyped queries). Once casts or a schema make the type
known, the message suggests it.
# error: [untyped-query] "SQL[UNTYPED] has no inferable row type: columns `x`, `y` are untyped; add casts (for example id::INTEGER) or a schema"
q = SQL[UNTYPED]("--sql\nSELECT x, y FROM pairs")
Fix: add casts or schema entries; sqlty check --fix then writes the row
type.