| name | diesel-guard |
| description | Lints Diesel and SQLx Postgres migrations for unsafe schema changes that lock tables or cause downtime, and authors custom Rhai checks. Use when reviewing, writing, or fixing SQL migrations, when diesel-guard reports a violation, when configuring diesel-guard.toml, or when the user mentions migration safety, table locks, or zero-downtime migrations. |
diesel-guard
diesel-guard is a CLI that lints Diesel and SQLx Postgres migrations using PostgreSQL's own parser
(libpg_query). It flags operations that take dangerous locks or rewrite tables, and prints a safe
alternative for each one. It needs no database connection — it reads .sql files directly. Use it to
vet a migration before it ships, to fix a reported violation, or to add project-specific rules.
Core workflow: run → interpret → fix
-
Run the linter:
diesel-guard check
diesel-guard check path/to/up.sql
cat up.sql | diesel-guard check -
diesel-guard check migrations/ --format json
-
Interpret the result. Exit code is the contract:
0 — clean, or only warnings (warnings never block).
1 — at least one error-level violation, or a fatal error (e.g. invalid diesel-guard.toml,
unparsable SQL, or a bad invocation). Read stderr to tell the two apart.
Each violation has three fields: operation (what was flagged), problem (why it's dangerous),
and safe_alternative (what to do instead). In JSON each also carries check_name, line, and
severity.
-
Fix by applying the printed safe_alternative, then re-run to confirm 0 (see below).
Fixing violations
Apply the safe_alternative printed for the violation, then re-run until the exit code is 0. Prefer
fixing the migration over suppressing the check.
Get the full reasoning and exact rewrite for any check by name — including the most common ones
(AddColumnCheck, AddIndexCheck, AddNotNullCheck) — from the tool itself:
diesel-guard explain <CheckName>
Treat that output and the violation's own safe_alternative as the source of truth; do not recite
fixes from memory.
Escape hatches (only when the operation is genuinely verified safe)
Configuring diesel-guard.toml
Run diesel-guard init to scaffold the file (use --force to overwrite). Keys:
framework (required) — "diesel" or "sqlx". Case-sensitive.
start_after — skip migrations older than this timestamp. Diesel accepts YYYYMMDDHHMMSS,
YYYY_MM_DD_HHMMSS, and YYYY-MM-DD-HHMMSS; SQLx accepts plain numeric versions like 42
and separator-formatted 14-digit timestamp filters. Good for retrofitting.
check_down (default false) — also check rollback/down migrations.
disable_checks — blacklist of check names to skip.
enable_checks — whitelist; only these run. Mutually exclusive with disable_checks.
warn_checks — demote these checks to warnings (reported, but exit stays 0).
custom_checks_dir — directory of .rhai custom checks.
postgres_version — target major version (e.g. 16); silences checks that are safe from that
version onward.
Discovering checks (the source of truth)
The tool serves the live, config-aware list — do not hardcode it:
diesel-guard list-checks
diesel-guard list-checks --format json
diesel-guard explain AddIndexCheck
Migration layouts
- Diesel — one directory per migration containing
up.sql (and optional down.sql,
metadata.toml). check scans up.sql recursively.
- SQLx — flat files:
<version>_<name>.up.sql / .down.sql, or single-file <version>_<name>.sql.
Writing custom checks
Write project-specific rules in Rhai with full access to the parsed SQL AST — no
forking required.
Setup
Point custom_checks_dir at a directory of .rhai files in diesel-guard.toml:
custom_checks_dir = "checks"
Every .rhai file in that directory becomes a check. They load in alphabetical order. A check's name
is its filename stem (require_concurrent_index.rhai → require_concurrent_index), so it can be
listed by list-checks, disabled via disable_checks/enable_checks, and explained via explain.
Compilation errors are non-fatal — they become warnings on stderr and the other checks still run.
Safety-assured blocks and -- diesel-guard:disable apply to custom checks too.
Inspect the AST with dump-ast
Before writing a check, see exactly what node a statement produces:
diesel-guard dump-ast --sql "CREATE INDEX idx ON t(id);"
diesel-guard dump-ast --file migrations/.../up.sql
The output strips the outer RawStmt/Node wrappers and starts at the concrete node type (e.g.
{"IndexStmt": {...}}) — that is precisely the shape your script receives as node.
Script inputs
Each script runs once per parsed statement with three variables in scope:
node — the AST node. Reach into the concrete type by name: node.IndexStmt.concurrent,
node.CreateStmt.relation.relname, node.DropStmt.remove_type. A field that doesn't apply to the
current statement is absent, so guard with ?? (see below).
config — the active configuration, e.g. config.postgres_version (an integer, or () when unset).
ctx — per-migration context: ctx.run_in_transaction (bool) and ctx.no_transaction_hint (a
framework-specific string explaining how to make the migration non-transactional).
Return protocol
Return exactly one of:
() — no violation.
#{ operation, problem, safe_alternative } — one violation. All three values must be strings.
[#{ ... }, #{ ... }] — an array of such maps for multiple violations.
A bad return value (wrong type, a map missing a key, or a non-string value) and any runtime error
thrown while the script runs do not crash diesel-guard — each produces a SCRIPT ERROR: <check-name>
violation. That violation is error severity by default, so it makes check exit 1 just like a
real finding; add the check's name to warn_checks in diesel-guard.toml to demote it to a warning.
Scripts never panic.
(This is distinct from compile errors at load time, described under Setup, which are
non-fatal and reported as warnings on stderr.)
pg:: constants
Reference pg_query protobuf enum values by name instead of raw integers via the pg:: module:
- Object types:
pg::OBJECT_INDEX, pg::OBJECT_TABLE, pg::OBJECT_COLUMN, pg::OBJECT_DATABASE,
pg::OBJECT_SCHEMA, pg::OBJECT_SEQUENCE, pg::OBJECT_VIEW, pg::OBJECT_FUNCTION,
pg::OBJECT_EXTENSION, pg::OBJECT_TRIGGER, pg::OBJECT_TYPE
- ALTER TABLE subtypes:
pg::AT_ADD_COLUMN, pg::AT_COLUMN_DEFAULT, pg::AT_DROP_NOT_NULL,
pg::AT_SET_NOT_NULL, pg::AT_DROP_COLUMN, pg::AT_ALTER_COLUMN_TYPE, pg::AT_ADD_CONSTRAINT,
pg::AT_DROP_CONSTRAINT, pg::AT_VALIDATE_CONSTRAINT
- Constraint types:
pg::CONSTR_NOTNULL, pg::CONSTR_DEFAULT, pg::CONSTR_IDENTITY,
pg::CONSTR_GENERATED, pg::CONSTR_CHECK, pg::CONSTR_PRIMARY, pg::CONSTR_UNIQUE,
pg::CONSTR_EXCLUSION, pg::CONSTR_FOREIGN
- Drop behavior:
pg::DROP_RESTRICT, pg::DROP_CASCADE
src/scripting.rs in the diesel-guard source is the authoritative list if you need one not shown here.
describe()
Optionally define fn describe() returning a string. diesel-guard explain <name> shows it.
fn describe() {
"Requires CONCURRENTLY on every CREATE INDEX."
}
Engine limits
Per script: max_operations 100,000 · max_string_size 10,000 · max_array_size 1,000 ·
max_map_size 1,000.
Worked example
Require CONCURRENTLY on CREATE INDEX, and also flag CONCURRENTLY used inside a transaction
(Postgres rejects that at runtime):
let stmt = node.IndexStmt ?? return;
if !stmt.concurrent {
let idx_name = if stmt.idxname != "" { stmt.idxname } else { "(unnamed)" };
return #{
operation: "INDEX without CONCURRENTLY: " + idx_name,
problem: "Creating index '" + idx_name + "' without CONCURRENTLY blocks writes on the table.",
safe_alternative: "Use CREATE INDEX CONCURRENTLY:\n CREATE INDEX CONCURRENTLY " + idx_name + " ON ...;"
};
}
if ctx.run_in_transaction {
let hint = if ctx.no_transaction_hint != "" { ctx.no_transaction_hint }
else { "Run this migration outside a transaction block." };
#{
operation: "INDEX CONCURRENTLY inside a transaction",
problem: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block; Postgres errors at runtime.",
safe_alternative: hint
}
}
node.IndexStmt ?? return; bails when the statement isn't a CREATE INDEX.
More examples
Five more complete, runnable checks. Together with the worked example above they cover the common
patterns: the ?? guard, optional chaining (?.), pg:: constants, returning an array, and
iterating child nodes. Copy one into your custom_checks_dir as a starting point.
Require IF EXISTS on DROP TABLE — uses a pg:: constant and missing_ok:
let stmt = node.DropStmt ?? return;
if stmt.remove_type != pg::OBJECT_TABLE || stmt.missing_ok { return; }
#{
operation: "DROP TABLE without IF EXISTS",
problem: "DROP TABLE without IF EXISTS will error if the table doesn't exist, potentially breaking migrations.",
safe_alternative: "Use IF EXISTS:\n DROP TABLE IF EXISTS <table_name>;"
}
Ban UNLOGGED tables — optional chaining on a nested field (relpersistence is "u" when unlogged):
let rel = node.CreateStmt?.relation ?? return;
if rel.relpersistence != "u" { return; }
let table_name = rel.relname;
#{
operation: "UNLOGGED TABLE: " + table_name,
problem: "UNLOGGED tables are not crash-safe and are not replicated to standby servers.",
safe_alternative: "Use a regular (logged) table instead:\n CREATE TABLE " + table_name + " (...);"
}
Ban TRUNCATE — iterate child nodes and return an array of violations:
let stmt = node.TruncateStmt ?? return;
let violations = [];
for rel in stmt.relations {
let name = rel.node?.RangeVar?.relname ?? continue;
violations.push(#{
operation: "TRUNCATE: " + name,
problem: "TRUNCATE acquires ACCESS EXCLUSIVE lock on '" + name + "', blocking all reads and writes for the duration. Unlike DELETE, it also resets sequences and skips triggers.",
safe_alternative: "Use batched DELETE instead:\n DELETE FROM " + name + " WHERE id IN (SELECT id FROM " + name + " LIMIT 1000);"
});
}
violations
Limit index width to 3 columns — read a config-like constant and count index_params:
let max_cols = 3;
let stmt = node.IndexStmt ?? return;
let col_count = stmt.index_params.len();
if col_count > max_cols {
let idx_name = if stmt.idxname != "" { stmt.idxname } else { "(unnamed)" };
#{
operation: "Wide index: " + idx_name + " (" + col_count + " columns)",
problem: "Index '" + idx_name + "' has " + col_count + " columns (limit: " + max_cols + "). Wide indexes are rarely effective and slow down writes.",
safe_alternative: "Use narrower indexes targeting specific query patterns, or partial/covering indexes."
}
}
Enforce an index naming convention — string method on a field:
let name = node.IndexStmt?.idxname ?? return;
if name == "" || name.starts_with("idx_") { return; }
#{
operation: "Index naming violation: " + name,
problem: "Index '" + name + "' does not follow naming convention. Index names should start with 'idx_'.",
safe_alternative: "Rename the index:\n CREATE INDEX idx_" + name + " ON ...;"
}