no-batch-insert-loop
Prevents INSERT/UPDATE/DELETE queries inside loops (N+1 query anti-pattern).
CWE: CWE-693
⚠️ This rule warns by default in the recommended config.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-400 (Resource Exhaustion) |
| Severity | Medium (performance) |
| Category | Security |
Rule Details
Executing mutation queries in loops creates N+1 query problems, causing:
- Database connection exhaustion
- Slow execution times
- Transaction timeout issues
❌ Incorrect
// N+1 inserts
for (const user of users) {
await client.query('INSERT INTO users VALUES ($1, $2)', [user.id, user.name]);
}
// forEach with mutations
users.forEach(async (user) => {
await pool.query('UPDATE users SET active = true WHERE id = $1', [user.id]);
});
// map with mutations
users.map((user) => client.query('INSERT INTO users VALUES ($1)', [user.id]));✅ Correct
// Bulk insert with unnest
await client.query(
'INSERT INTO users SELECT * FROM unnest($1::int[], $2::text[])',
[users.map((u) => u.id), users.map((u) => u.name)],
);
// pg-format for batching
const values = users.map((u) => [u.id, u.name]);
await client.query(format('INSERT INTO users VALUES %L', values));
// ANY for bulk updates
await client.query('UPDATE users SET active = true WHERE id = ANY($1)', [
userIds,
]);Error Message Format
The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:
🔒 CWE-400 OWASP:A06 CVSS:7.5 | Uncontrolled Resource Consumption (ReDoS) detected | HIGH
Fix: Review and apply the recommended fix | https://owasp.org/Top10/A06_2021/Message Components
| Component | Purpose | Example |
|---|---|---|
| Risk Standards | Security benchmarks | CWE-400 OWASP:A06 CVSS:7.5 |
| Issue Description | Specific vulnerability | Uncontrolled Resource Consumption (ReDoS) detected |
| Severity & Compliance | Impact assessment | HIGH |
| Fix Instruction | Actionable remediation | Follow the remediation steps below |
| Technical Truth | Official reference | OWASP Top 10 |
Known False Negatives
The following patterns are not detected due to static analysis limitations:
Async Iterators
Why: The rule doesn't traverse for await...of as a loop type.
// ❌ NOT DETECTED
async function* getUsers() {
yield* users;
}
for await (const user of getUsers()) {
await client.query('INSERT INTO users VALUES ($1)', [user.id]);
}Recursive Functions
Why: Recursion creates implicit loops that require call graph analysis.
// ❌ NOT DETECTED
async function insertUsers(users: User[], i = 0): Promise<void> {
if (i >= users.length) return;
await client.query('INSERT INTO users VALUES ($1)', [users[i].id]);
return insertUsers(users, i + 1); // Recursive N+1!
}One round trip per ROW
A loop is reported when its statement is the same on every pass and its parameters change — one row per round trip. A loop whose statement itself differs per pass (a migration runner reading a different file each time, an element of a list of statements, a statement built from the loop element) is a sequence of distinct statements and is not reported, nor is the identical statement repeated with no parameters.
Promise.all with Mapping
A row-WRITING statement (INSERT / UPDATE / DELETE / MERGE) inside .map(),
.flatMap() or an Array.from(xs, mapper) mapper is reported whatever
consumes the array (since 2026-10). Reads in
Promise.all(ids.map(…)) stay quiet: that is the deliberate concurrency
trade-off. A loop over chunks of an already-batched statement (unnest(…),
jsonb_to_recordset(…), pg-format %L) is the remediation and is not
reported, nor is keyset pagination with LIMIT in for (;;).
// ❌ REPORTED (since 2026-10) - Array.from pattern
const queries = Array.from(users, (u) =>
client.query('INSERT INTO users VALUES ($1)', [u.id]),
);
await Promise.all(queries);External Loop Functions
Why: Queries called from a helper function inside a loop are not detected.
// ❌ NOT DETECTED
async function insertUser(user: User) {
await client.query('INSERT INTO users VALUES ($1)', [user.id]);
}
for (const user of users) {
await insertUser(user); // N+1 but rule can't see into helper
}Workaround: Use
UNNEST()for bulk inserts orANY()for bulk updates.
When Not To Use It
- When processing results in a streaming fashion with backpressure
- For operations that must be transactionally isolated per item
- When using a batching library (e.g., DataLoader) that coalesces queries
Related Rules
- no-select-all - Query performance
Use this rule
npm install -D oxlint-plugin-postgresql-security{ "jsPlugins": ["oxlint-plugin-postgresql-security"], "rules": { "postgresql-security/no-batch-insert-loop": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-postgresql-security'], rules: { 'postgresql-security/no-batch-insert-loop': 'error', }, },});oxlint-plugin-postgresql-security re-exports eslint-plugin-postgresql-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.