Skip to content

Rules

sqlty check reports diagnostics as file:line: [rule] message. Each rule has a default severity, changed per rule in [tool.sqlty.rules]:

[tool.sqlty.rules]
column-name-mismatch = "error"
stale-type = "error"

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: ignore drops 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.

[tool.sqlty]
schema = "schema.sql"

schema.sql:

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
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.

[tool.sqlty]
schema = "schema.sql"

schema.sql:

CREATE TABLE orders (id INTEGER PRIMARY KEY, total NUMERIC(10, 2) NOT NULL);
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.

[tool.sqlty]
schema = "schema.sql"

[tool.sqlty.rules]
redundant-optional = "warn"

schema.sql:

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
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.

[tool.sqlty]
schema = "schema.sql"

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.

[tool.sqlty]
schema = "schema.sql"

[tool.sqlty.rules]
redundant-literal = "warn"

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:

'# sqlty: ignore[select-star]' suppresses no diagnostic

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:

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
[tool.sqlty]
schema = "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.

[tool.sqlty]
dialect = "mysql"
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.

[tool.sqlty]
preview = false

[tool.sqlty.rules]
dynamic-query = "warn"
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.