The incident
Staging has fifty rows, so every query is fast. Production has five million, and the same query pins the CPU. Plinth flags it while you are still typing it.
Flagged in the editor, before review
The index advisor for Prisma, Drizzle and TypeORM, in VS Code
Plinth reads your queries as you write them and finds the indexes worth adding, weighing each one against what it costs your writes. Then it writes the fix in your ORM's own terms, for PostgreSQL and MySQL. Before the pull request, not after the page.
7export async function recentOrders(status: OrderStatus) {8 return prisma.order.findMany({9 where: { status },10 orderBy: { createdAt: "desc" },11 take: 50,12 });13}
"Order": { "rows": 5000000, "distinct": { "status": 4 }}
order.findMany filters on status and sorts by createdAt, but no index on Order supports it.
4 reads : 0 writes in code
Without a supporting index PostgreSQL reads every row of "orders" and sorts the result on each call. Fast with staging data, slow at production scale. Order is declared at ~5M rows in plinth.config.json. Write cost: every insert and delete on "orders", and every update to status and createdAt, also updates this index. Confidence: high.
Add @@index([status, createdAt]) to Order and create its migration
That query on 5 million orders, PostgreSQL 16
Every call stops reading millions of rows to return 50. That is CPU your database gets back on every request, all day.
Measured with EXPLAIN ANALYZE on Plinth's example shop project, seeded with 5 million orders.
A missing index costs you in four places: on call, on the cloud bill, on every write and in review. Plinth deals with each before the code ships.
Staging has fifty rows, so every query is fast. Production has five million, and the same query pins the CPU. Plinth flags it while you are still typing it.
Flagged in the editor, before review
A full table scan burns CPU on every call, and a missing index often looks like a capacity problem. Fix the index and the CPU comes back, so you can stay on your current instance size or move down one instead of paying for the next one up.
≈ 300 ms to ≈ 0.2 ms on 5M rows
Every index slows inserts and updates. Plinth states the write cost of each one it suggests, extends an index you already have instead of adding another, and holds back on tables you mark write-heavy. Reads get faster without piling indexes onto your writes.
Write cost stated on every suggestion
Nobody has to spot the unindexed sort in a 600-line diff. The warning is on the line, with the reasoning and the fix next to it.
Reasoning and fix on the line
As you type, Plinth parses your ORM calls against your schema, whether Prisma models, Drizzle tables or TypeORM entities: filters, sort keys, limits, existing indexes, foreign keys.
Static code cannot see how big a table is, so you tell it, roughly, in plinth.config.json. Or run one read-only stats query: it returns row estimates, distinct counts and traffic counters, never row contents. Sizes can come from a replica; traffic and index usage need the primary. Every index also slows inserts and updates, so each suggestion states its write cost and the read/write mix behind it. Every warning shows its confidence.
One click adds the index to your schema. For Prisma and TypeORM it also writes the migration, with the same index name, so your migration tool reports no drift; for Drizzle, drizzle-kit generates it. Online DDL on MySQL, and CONCURRENTLY on PostgreSQL wherever your migration runner allows it.
model Order { … @@map("orders") @@index([status, createdAt])}
CREATE INDEX CONCURRENTLY IF NOT EXISTS "orders_status_created_at_idx" ON "orders" ("status", "created_at");
Apply it with prisma migrate deploy.
Six rules and one habit. Every index it suggests is weighed against what it costs your writes.
unindexed-query
A filter or sort with no index behind it. Plinth suggests the composite index that serves both, in your ORM's own syntax.
One-click fix, with the migration
missing-fk-index
PostgreSQL never creates them, and MySQL doesn't when Prisma's relationMode is "prisma". Joins and cascading deletes pay for it.
One-click fix, with the migration
unused-indexNew
An index the database recorded no reads through for a week or more, while every write still maintains it. Never a unique or primary key.
Needs stats synced from the primary
redundant-indexNew
A duplicate, or an index on (userId) next to one on (userId, createdAt). The wider one serves every query, yet every write maintains both.
One-click removal, with the migration
unbounded-query
No take or limit, so the result, and the memory it takes, grows with the table.
Warning with the reasoning
query-in-loop
One round trip per item, the N+1. It knows Prisma batches findUnique, so those pass.
Warning with the reasoning
on every fix
Extends an index you already have instead of adding a second, and folds a foreign-key index into the query index that covers it.
Writes maintain fewer indexes
An index speeds up reads and is paid for on every write. So each index warning shows the table's read/write mix next to it, and Plinth trusts the strongest evidence it has.
From your code[4 reads : 0 writes in code]
With synced stats[query ~28/s · rows written ~15/s measured]
From pg_stat_statements or MySQL statement digests. A query that runs 100 or more times an hour keeps its index, even on a write-heavy table. One that runs less than once an hour on a table taking 1,000+ row writes an hour drops a level.
Measured on the table. When the query’s own count is unknown, a busy table writing ten times more rows than it reads lowers the confidence a level.
Mark a table writeHeavy in plinth.config.json and, without synced stats, its index suggestions drop a level.
Reads and writes counted across the project, leaving out tests, seeds, scripts and migrations. Shown, never acted on: one hot read can outweigh a hundred rare writes.
On PostgreSQL it also says when your code updates the new index's columns, because those updates can no longer be HOT (heap-only) updates.
Runs locally. Your code and schema never leave your machine, and database stats come only from what you sync into plinth.config.json.
One file next to your code. Scroll to fill it in.
{ "dialect": "postgresql", "smallTableRows": 10000, "largeTableRows": 100000, "stats": { "hours": 336, "statementHours": 336 }, "tables": { "orders": { "rows": 5000000, "distinct": { "status": 4, "user_id": 380000 }, "writeHeavy": true, "traffic": { "reads": 120000, "inserts": 8400000, "updates": 52000, "hotUpdates": 41000, "deletes": 0 }, "statements": [ { "op": "select", "columns": ["status", "created_at"], "calls": 9400000 } ], "indexes": [ { "name": "orders_region_idx", "columns": ["region"], "scans": 0 } ], "planner": [ { "columns": ["status", "created_at"], "used": true, "costBefore": 6588, "costAfter": 3.2 } ] } }, "ignore": [ { "rule": "unbounded-query", "file": "src/admin/**", "reason": "Admin export, runs nightly", "by": "sam" } ]}
Comments are drawn for this tour. The real file is plain JSON.
rows: How big the table is. Rough is fine.distinct: Values per column. Few values, weak index.
writeHeavy: Your guess: writes dominate this table.
stats: The two weeks the counters cover.traffic: Measured: ~25k rows written an hour.statements: How often each query ran. No values kept.
indexes: Real indexes. Never read: unused.planner: PostgreSQL's verdict on the suggested index.
ignore: A team decision, always with a reason.smallTableRows: Smaller tables: Plinth stays quiet.
largeTableRows: Larger tables: high confidence.dialect: Read from your ORM if left out.Three ORMs, two databases, one set of rules. Each fix is written the way your ORM expects it, so your existing migration workflow stays the same.
| ORM | PostgreSQL | MySQL |
|---|---|---|
| Prisma @@index + migration.sql | ||
| Drizzle index().on() + drizzle-kit | ||
| TypeORM @Index + migration class |
Plinth vs. AI review
Plinth is a static analyzer for database access. It reads your ORM queries and your schema, finds the queries that will slow down at production size, weighs each fix against what it costs your writes, and writes that fix for you. No model, no sampling: the same input always gives the same answer.
| What matters | Plinth | AI code review |
|---|---|---|
| Same code, same answer | The same code, schema and config give the same findings, every run and every machine. Nothing to re-roll. | Model output can vary between runs, and two reviewers can disagree. |
| Sees the whole schema | Reads every model, table and entity: its indexes, unique keys and foreign keys, not just the lines in the diff. | Usually sees the diff and some surrounding context. |
| Knows how big your tables are | Weighs each query against the table sizes and traffic you sync into plinth.config.json. | Can't see your production data, so it has to guess at scale. |
| Counts the cost of every index | States the write cost of each suggestion and backs off on tables that write far more than they read. | Tends to suggest adding an index without counting what it costs your writes. |
| Writes the fix, not a hint | The exact index in your ORM's syntax, plus the migration, with the same index name so your tooling reports no drift. | Suggests a change for you to write and check. |
| Explains itself | Every finding has a rule ID, its reasoning and a confidence level, so you can check it, and silence it by name on the one line that needs it. | Explanations are free text and harder to audit. |
| Runs on your machine | No account, no API calls, no tokens. Your code and schema never leave the machine. | Usually sends your code to a model provider. |
The extension is free, with every rule and every fix. Pro keeps slow queries out of your pull requests; Teams does it for everyone and shows what they cost.
$0 forever
For every developer, on every project.
$10 per month, for one developer
$8 a month billed annually.
$15 per seat / month
$12 per seat a month billed annually. Volume pricing available.
Pro is for one developer. Teams is billed per seat: one for each developer on the team. Want it now? We run an index audit on your codebase and replica stats and hand you the ranked fixes, credited toward a year of Pro or Teams.
Install Plinth and open a file that queries your database. Findings appear on the line.