Skip to main content
interlace
Plugin: pgRules

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

AspectDetails
CWE ReferenceCWE-400 (Resource Exhaustion)
SeverityMedium (performance)
CategorySecurity

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

ComponentPurposeExample
Risk StandardsSecurity benchmarksCWE-400 OWASP:A06 CVSS:7.5
Issue DescriptionSpecific vulnerabilityUncontrolled Resource Consumption (ReDoS) detected
Severity & ComplianceImpact assessmentHIGH
Fix InstructionActionable remediationFollow the remediation steps below
Technical TruthOfficial referenceOWASP 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 or ANY() 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

Use this rule

npm install -D oxlint-plugin-postgresql-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-postgresql-security"],  "rules": {    "postgresql-security/no-batch-insert-loop": "error"  }}
vite.config.ts
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.