# Rules

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

```toml
[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](<https://sqlty.kuncar.dev/reference/cli/#configuration-flags>).

| Rule | Group | Default | Fixable | Reports |
| --- | --- | --- | --- | --- |
| [`column-count-mismatch`](<#column-count-mismatch>) | Row type | error | no | Row type has a different number of fields than the query has columns |
| [`column-type-mismatch`](<#column-type-mismatch>) | Row type | error | no | Row type field can't hold its column's values |
| [`column-name-mismatch`](<#column-name-mismatch>) | Row type | warn | no | `NamedTuple` field name differs from its column name |
| [`nullable-mismatch`](<#nullable-mismatch>) | Row type | error | no | Row type field is not optional but its column can be NULL |
| [`json-shape-mismatch`](<#json-shape-mismatch>) | Row type | error | no | Row type field can't hold the JSON its column builds |
| [`redundant-optional`](<#redundant-optional>) | Row type | ignore | no | Row type field is optional but its column cannot be NULL |
| [`literal-mismatch`](<#literal-mismatch>) | Row type | error | no | Row type field is a `Literal` that does not cover its column's values |
| [`redundant-literal`](<#redundant-literal>) | Row type | ignore | no | Row type field is a `Literal` that lists values its column never takes |
| [`typeddict-key-mismatch`](<#typeddict-key-mismatch>) | Row type | error | no | Columns don't fit the fields of a TypedDict, dataclass or Pydantic model |
| [`typeddict-inheritance`](<#typeddict-inheritance>) | Row type | error | no | TypedDict subclass breaks a closed or `extra_items` base (PEP 728) |
| [`select-star-no-schema`](<#select-star-no-schema>) | Query | error | no | `SELECT *` with no schema to expand it |
| [`select-star`](<#select-star>) | Query | warn | yes | `SELECT *` |
| [`inline-sql-comment`](<#inline-sql-comment>) | Query | warn | yes | `"--sql SELECT ..."`: the query is a SQL comment |
| [`mysql-sql-marker`](<#mysql-sql-marker>) | Query | warn | yes | `--sql` marker with `dialect = "mysql"`, which MySQL rejects |
| [`unparsed-query`](<#unparsed-query>) | Query | warn | no | Query that does not parse, so sqlty cannot check it |
| [`sqruff`](<#sqruff>) | Query | ignore | no | Finding of sqruff's core lint rules in a query |
| [`unknown-table`](<#unknown-table>) | Names | error | no | Table the schema does not define |
| [`unknown-column`](<#unknown-column>) | Names | error | yes | Column the table does not have |
| [`ambiguous-column`](<#ambiguous-column>) | Names | error | no | Unqualified column more than one table has |
| [`unknown-function`](<#unknown-function>) | Names | warn | no | Function whose result sqlty cannot type |
| [`unknown-type`](<#unknown-type>) | Names | warn | no | Cast or column type without a Python type |
| [`unsupported-syntax`](<#unsupported-syntax>) | Names | warn | no | Construct that parses but is not typed |
| [`insert-missing-column`](<#insert-missing-column>) | Names | error | no | INSERT that leaves out a `NOT NULL` column without a default |
| [`insert-value-count`](<#insert-value-count>) | Names | error | no | INSERT that gives a different number of values than the columns it writes |
| [`ungrouped-column`](<#ungrouped-column>) | Names | error | no | Grouped query selects a column it neither groups by nor aggregates |
| [`fanout-aggregate`](<#fanout-aggregate>) | Names | warn | no | Aggregate over rows a one-to-many join repeats |
| [`chasm-join`](<#chasm-join>) | Names | warn | no | Aggregating query joins one table one-to-many to two others |
| [`unproven-join`](<#unproven-join>) | Names | ignore | no | Join in an aggregating query that matches no primary or unique key |
| [`entity-mismatch`](<#entity-mismatch>) | Names | ignore | no | Comparison between columns holding keys of different tables |
| [`stale-rewrite`](<#stale-rewrite>) | Rewrite | warn | yes | `--sql` string not wrapped in `SQL[T](...)` |
| [`stale-type`](<#stale-type>) | Rewrite | warn | yes | `SQL[tuple[...]]` wider than the inferred row type |
| [`narrow-type`](<#narrow-type>) | Rewrite | error | yes | `SQL[tuple[...]]` doesn't admit every row the query returns |
| [`fstring-skipped`](<#fstring-skipped>) | Rewrite | warn | no | f-string with `--sql` cannot be rewritten |
| [`tstring-skipped`](<#tstring-skipped>) | Rewrite | warn | no | t-string with `--sql` and a field that changes the SQL |
| [`unchecked-query`](<#unchecked-query>) | Rewrite | warn | no | `--sql` query that is not plain string literals |
| [`dynamic-query`](<#dynamic-query>) | Rewrite | ignore | no | Helper query that is not a string literal |
| [`untyped-query`](<#untyped-query>) | Rewrite | warn | yes | `SQL[UNTYPED]` query |
| [`param-count-mismatch`](<#param-count-mismatch>) | Params | error | no | Call passes a different number of parameters than its query has |
| [`param-type-mismatch`](<#param-type-mismatch>) | Params | error | no | Call passes an argument the query's parameter can't take |
| [`params-type-mismatch`](<#params-type-mismatch>) | Params | error | no | `P` of `SQL[R, P]` doesn't fit the query's parameters |
| [`unused-ignore`](<#unused-ignore>) | Suppression | warn | no | `# sqlty: ignore` comment that suppresses nothing |

`nullable-mismatch` and `redundant-optional` check nullability; see [Nullability](<https://sqlty.kuncar.dev/guide/schema-inference/#nullability>). `literal-mismatch` and `redundant-literal` check `Literal` annotations against the values a column can take; see [Literal values](<https://sqlty.kuncar.dev/guide/schema-inference/#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.

```python
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`](<#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](<https://sqlty.kuncar.dev/guide/row-types/index.md>) (`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.

```python
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`.

```python
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`.

```python
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`.

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

`schema.sql`:

```sql
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
```

```python
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 `TypedDict`s, `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](<https://sqlty.kuncar.dev/guide/json-shapes/index.md>) and `sqlty rule json-shape-mismatch`.

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

`schema.sql`:

```sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, total NUMERIC(10, 2) NOT NULL);
```

```python
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`.

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

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

`schema.sql`:

```sql
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
```

```python
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`.

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

`schema.sql`:

```sql
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'refunded'))
);
```

```python
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`.

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

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

`schema.sql`:

```sql
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN ('pending', 'paid'))
);
```

```python
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.

```python
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](<https://sqlty.kuncar.dev/guide/row-types/#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.

```python
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](<https://sqlty.kuncar.dev/guide/row-types/#typeddict-inheritance>) for what a subclass may do.

## Parameter rules

Both rules read the arguments a [query helper](<https://sqlty.kuncar.dev/guide/helpers/index.md>) call passes for the query's placeholders; see [Query parameters](<https://sqlty.kuncar.dev/guide/parameters/index.md>).

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

```python
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.

```python
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](<https://sqlty.kuncar.dev/guide/parameters/#typed-parameters>).

```python
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:

```text
'# 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.

```python
# 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](<https://sqlty.kuncar.dev/guide/schema-inference/index.md>). 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`:

```sql
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
```

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

```python
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.

```python
# 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.

```toml
[tool.sqlty]
dialect = "mysql"
```

```python
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.

```python
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.

### sqruff

Default: ignore. What the core lint rules of [sqruff](<https://github.com/quarylabs/sqruff>) find in a query, in the project's dialect; the message starts with the sqruff rule code. Its layout rules are left to [`sqlty format`](<https://sqlty.kuncar.dev/guide/formatting/index.md>), and a finding on a placeholder is dropped. Off because the rules judge style rather than types.

```toml
[tool.sqlty.rules]
sqruff = "warn"
```

```python
from sqlty import SQL

# error: [sqruff] "AM06: Inconsistent column references in 'GROUP BY/ORDER BY' clauses."
q = SQL[tuple[str]]("--sql\nSELECT name FROM users GROUP BY 1 ORDER BY name")
```

## 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. `fanout-aggregate`, `chasm-join` and `unproven-join` check what joins do to aggregates, and `entity-mismatch` what they compare. 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](<https://sqlty.kuncar.dev/guide/name-diagnostics/index.md>) shows how to change them. All but `unsupported-syntax` are only judged when a [schema](<https://sqlty.kuncar.dev/guide/schema-inference/index.md>) 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. When one column of the tables in scope is closest to the name, the message suggests it and `--fix` writes it.

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

### fanout-aggregate

Default: warn. An aggregate such as `SUM`, `AVG` or `COUNT(column)` over a table whose rows a one-to-many join repeats: `AVG(orders.total)` over orders joined to their items. Keys are primary keys, `UNIQUE` constraints, unique indexes without `WHERE`, and the `GROUP BY` or `DISTINCT` columns of a CTE or derived table. `MIN`, `MAX` and `DISTINCT` aggregates are not reported.

### chasm-join

Default: warn. An aggregating query that joins one table one-to-many to two others that don't join each other, such as orders to items and to payments: each row of one branch repeats once per row of the other.

### unproven-join

Default: ignore. A join in an aggregating query whose conditions match no primary or unique key on either side, so whether it repeats rows is unknown. Off because a schema without declared keys would report every join.

### entity-mismatch

Default: ignore. A comparison in `ON`, `USING`, `WHERE`, `HAVING` or `IN` between columns holding keys of different tables: `products.id = orders.customer_id`. A key column holds keys of its table, and a foreign key column those of the key it references, followed to the end of the chain. Off because intentional joins of unrelated keys (polymorphic ids, legacy schemas) would need suppressions.

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

```python
# 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.

```python
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](<https://sqlty.kuncar.dev/guide/writing-queries/#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.

```python
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:

```python
# 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](<https://peps.python.org/pep-0750/>)) 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](<https://sqlty.kuncar.dev/guide/writing-queries/#t-strings>).

```python
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.

```python
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](<https://sqlty.kuncar.dev/guide/query-variants/index.md>)), and one with more variants than `max-query-variants` is reported here.

```toml
[tool.sqlty]
preview = false

[tool.sqlty.rules]
dynamic-query = "warn"
```

```python
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](<https://sqlty.kuncar.dev/guide/untyped/index.md>)). Once casts or a schema make the type known, the message suggests it.

```python
# 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.
