#30487·Prisma

Memory grows without limit when $transaction([...]) batches vary in shape

Author: s-minavCreated Sep 29, 2026Updated Sep 29, 2026
Labelsbug/1-unconfirmedkind/bug

Package and version

@prisma/[email protected]

What happened?

Our API ran out of heap roughly every 29 hours after moving to Prisma 7. We traced it to the query plan cache: every $transaction([...]) whose shape changes from call to call adds a new entry that is never reused, and large batches make each entry several megabytes.

By "shape" I mean anything that survives parameterization: the number of queries in the batch, and whether a field is null or a value. A batch built from data - e.g. rows.map(r => prisma.x.upsert(...)) - almost never repeats its shape, so almost every call is a cache miss that gets stored.

The cache is limited to 1000 entries, but not by size. With ~450 upserts per batch each entry is ~10 MB, so the process runs out of memory long before the limit matters.

What did you expect to happen?

The cache should not be able to exhaust memory. Some options:

  • don't cache batch plans at all, or not above N queries;
  • limit the cache by size instead of entry count.

Minimal reproduction

Schema:

prisma
model Metric {
  key     String
  hour    DateTime
  checks  Int
  latency Int?
  speed   Int?
  @@id([key, hour])
}

Script (node --expose-gc repro.js):

javascript
const { PrismaClient } = require('@prisma/client');
const { PrismaMariaDb } = require('@prisma/adapter-mariadb');
const prisma = new PrismaClient({
  adapter: new PrismaMariaDb({ host: '127.0.0.1', port: 3306, user: 'root', password: 'pw', database: 'repro' }),
});

const rows = () => Array.from({ length: 400 + Math.floor(Math.random() * 70) }, (_, i) => ({
  key: `k${i % 20}-${Math.floor(i / 20)}`,
  hour: new Date(Date.UTC(2026, 0, 1, i % 24)),
  checks: 4,
  latency: Math.random() < 0.2 ? null : 80,
  speed: Math.random() < 0.7 ? null : 90000,
}));
const upsert = (db, r) => db.metric.upsert({
  where: { key_hour: { key: r.key, hour: r.hour } },
  create: r,
  update: { checks: r.checks, latency: r.latency, speed: r.speed },
});

(async () => {
  for (let i = 0; i <= 40; i++) {
    if (i % 4 === 0) {
      global.gc();
      console.log(i, (process.memoryUsage().heapUsed / 1048576).toFixed(1), 'MB');
    }
    await prisma.$transaction(rows().map((r) => upsert(prisma, r)));
  }
  await prisma.$disconnect();
})();

The table starts empty; the script fills it itself.

Output on 7.10.0 (heapUsed after GC, MB):

batch # $transaction([...]) interactive $transaction(async tx => …)
0 8.0 8.0
8 99.7 14.2
16 171.8 14.4
24 249.8 14.4
32 330.7 14.5
40 408.8 14.6

~10 MB is retained per batch with the array form. The interactive variant writes exactly the same rows, one await at a time ($transaction(async (tx) => { for (const r of rows) await upsert(tx, r) })), and stays flat.

Environment

  • @prisma/client 7.10.0, @prisma/adapter-mariadb 7.10.0, mariadb 3.4.5
  • MariaDB 12.3
  • Node.js 24
  • OS: Linux (Debian 12, Docker) in production, Windows 11 for the repro

Additional context

  • Batches with createMany get a new cache entry for every distinct row count. The single-query path already skips caching for createMany; the batch path doesn't.
  • Maybe related to #29294 (OOM after 7.3.0 → 7.4.1), but I haven't verified that.
  • Workaround: interactive transactions with sequential awaits for data-dependent writes.