eslint-plugin-postgresql
An ESLint plugin for PostgreSQL .sql files. It catches syntax errors and
enforces PostgreSQL-specific best practices, powered by
postgresql-eslint-parser
and libpg-query.
📚 Documentation & Playground: https://baseballyama.github.io/eslint-plugin-postgresql/
Installation
npm install --save-dev eslint-plugin-postgresql
pnpm add -D eslint-plugin-postgresql
Usage
Add the plugin to your ESLint flat config (eslint.config.js):
import postgresql from "eslint-plugin-postgresql";
export default [
{
files: ["**/*.sql"],
...postgresql.configs.recommended,
},
];
To override individual rule severities, add another config block after the
recommended preset:
import postgresql from "eslint-plugin-postgresql";
export default [
{
files: ["**/*.sql"],
...postgresql.configs.recommended,
},
{
files: ["migrations/**/*.sql"],
rules: {
"postgresql/require-limit": "off",
},
},
];
Stylistic preset (optional)
Layout / casing / formatting rules live in a separate configs.stylistic
preset. PostgreSQL formatters (prettier-plugin-sql, pg_format) do not
cover PL/pgSQL well, so this plugin ships an opt-in stylistic layer. Every
rule in configs.stylistic is auto-fixable.
import postgresql from "eslint-plugin-postgresql";
export default [
{
files: ["**/*.sql"],
...postgresql.configs.recommended,
},
{
files: ["**/*.sql"],
...postgresql.configs.stylistic,
},
];
All preset (everything-on)
configs.all enables every rule the plugin ships at error. It is
deliberately noisy — useful for exploring what the plugin can catch, or
for greenfield projects that want to audit and then disable rules they
don't want. Not recommended for an existing codebase without overrides.
import postgresql from "eslint-plugin-postgresql";
export default [
{
files: ["**/*.sql"],
...postgresql.configs.all,
},
];
SQL written in TypeScript (Drizzle, Prisma, Kysely, postgres.js)
ORMs keep SQL in two places, and the plugin reaches them differently.
Migration files. drizzle-kit generate and prisma migrate dev both
write plain .sql migrations. They need no extra setup — point the presets
at the output directory and every DDL rule applies. For a schema-first ORM
this is where nearly all the value is, since every table your app has passes
through here:
import postgresql from "eslint-plugin-postgresql";
export default [
{
files: ["drizzle/**/*.sql", "prisma/migrations/**/*.sql"],
...postgresql.configs.recommended,
},
];
sql template literals. For SQL embedded in .ts / .js, the
embedded-sql processor carves each statement out into a virtual .sql
file, lints it with the normal rules, and maps the reports (and any fixes)
back to the position in the original file:
import postgresql from "eslint-plugin-postgresql";
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommended,
{
files: ["**/*.sql"],
...postgresql.configs.recommended,
},
{
files: ["src/**/*.ts"],
processor: postgresql.processors["embedded-sql"],
},
];
The **/*.sql block matches the virtual files too, so one preset covers
both real and embedded SQL. The host file keeps being linted by its own
config, type-aware rules included — typescript-eslint above still sees it.
Turning off the rules the virtual files inherit
A flat-config entry with no files key applies to every file ESLint
lints, and the virtual .sql files are no exception. In a project where
typescript-eslint is registered without a files restriction — the common
case — its rules are handed a PostgreSQL AST and a type-aware rule will throw
outright:
Error while loading rule '@typescript-eslint/await-thenable': You have used a
rule which requires type information, but don't have parserOptions set to
generate type information for this file.
Occurred while linting src/db.ts/0_0.sql
Switch those rules off on the virtual files, the same way you would for a
real .sql file. Put the block last so it wins:
import tseslint from "typescript-eslint";
{
files: ["**/*.ts/*.sql"],
rules: {
...tseslint.configs.disableTypeChecked.rules,
// …plus any other plugin whose rules are registered without `files`.
},
}
If you already have a config block that neutralises your JS/TS rules for
**/*.sql files, point it at **/*.ts/*.sql as well and you are done.
What the processor picks up, and what it leaves alone:
- Only complete statements.
sql`SELECT id FROM users` is linted;
expression fragments like sql`excluded.user_name` or
sql`${users.id} IS NULL` have no standalone parse and are skipped. So
are pieces of an ALTER TABLE that start with a statement keyword, such as
sql`DROP COLUMN ${sql(column)}` , sql`SET NOT NULL` or a bare
sql`DROP` .
- Interpolations become placeholders. Each
${...} is replaced by an
identifier of exactly the same width, so positions stay accurate. Where an
identifier can never parse, the placeholder takes the shape the ${...}
must expand to, as postgres.js's sql(...) helpers do: a list after IN
or VALUES, rows after INSERT INTO <table>, and assignments after SET
(unless = or TO follows). Reports and fixes that land on a placeholder
are discarded rather than shown — the plugin will not tell you to rename an
expression it cannot see. If the substituted text fails to parse on a
placeholder, or right after one at the end of the statement or on a
reserved word such as SELECT, no syntax error is reported: that ${...}
holds a clause or a list, not a name. Rules that read tokens rather than
the syntax tree, such as prefer-keyword-case and
prefer-not-equals-operator, still check the template. A syntax error
anywhere else is reported.
- Templates containing a
\ escape are skipped, because the raw source
and the string that actually reaches PostgreSQL differ there.
- Recognised tags are
sql (Drizzle, Kysely, postgres.js) and
$queryRaw / $executeRaw (Prisma), matched on the last identifier of the
tag's member path, so prisma.$queryRaw matches. Call and type arguments
after the tag are skipped, so sql<Row[]>`...` matches too. Other
tags, such as modern slonik's sql.type(schema)`...` , take
a processor of your own. Prisma's $queryRawUnsafe("...")
and TypeORM's .query("...") are out of reach: they take a string argument
rather than a template.
- The ORM query builder is out of scope.
db.select().from(users) and
pgTable(...) never produce SQL text, so no rule can see them. Lint the
generated migrations instead.
Rules written for standalone files can be noisy here — require-trailing-semicolon
flags every embedded statement, for instance. Turn those off in the same
**/*.ts/*.sql block:
{
files: ["**/*.ts/*.sql"],
rules: { "postgresql/require-trailing-semicolon": "off" },
}
Other tags
createEmbeddedSqlProcessor builds the same processor for tags of your own.
It takes a predicate that is given each tag's member path — the identifiers
before the backtick joined by ., without call or type arguments — and
returns whether the template holds SQL. For modern slonik, whose tags include
sql.type(schema)`...` (path sql.type) and sql.unsafe`...` :
import { createEmbeddedSqlProcessor } from "eslint-plugin-postgresql";
{
files: ["src/**/*.ts"],
processor: createEmbeddedSqlProcessor(
(path) => path === "sql" || path.startsWith("sql."),
),
}
Only a template that starts with a statement keyword becomes a virtual file,
so also accepting fragment tags such as sql.fragment is harmless.
Rules
Click a rule name to open its documentation page (examples, rationale, options).
Legend
- ✅ — enabled as
error in configs.recommended
- ⚠️ — enabled as
warn in configs.recommended
- 🎨 — enabled in
configs.stylistic (auto-fixable)
- (blank) — off by default; opt in per project
Contributing
Issues and pull requests are welcome — please follow the templates. See
CLAUDE.md for the contributor guide and project conventions.
License
MIT
Related projects