The index advisor for Prisma, Drizzle and TypeORM, in VS Code

Saves the outage your staging data never shows you.

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}

plinth.config.json You declare

"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

Before
≈ 300 ms parallel scan of the whole table, then a sort
After Plinth's migration
≈ 0.2 ms backward scan of the new index, 50 rows read

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.

What Plinth saves you and your team from

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.

Reliability

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

Cost

The compute bill

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

Write performance

The write path

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

Team time

The review hour

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

How it works

  1. Reads your queries and schema

    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.

  2. Weighs them against your table sizes and your writes

    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.

  3. Writes the fix in your ORM's own terms

    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.

prisma/schema.prisma
model Order {  …  @@map("orders")  @@index([status, createdAt])}
prisma/migrations/20261010143000_add_orders_status_created_at_index/migration.sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS "orders_status_created_at_idx" ON "orders" ("status", "created_at");

Apply it with prisma migrate deploy.

What it catches today

Six rules and one habit. Every index it suggests is weighed against what it costs your writes.

unindexed-query

Unindexed filters and sorts

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

Foreign keys without an 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

Indexes nobody reads

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

Indexes another index covers

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

Unbounded list queries

No take or limit, so the result, and the memory it takes, grows with the table.

Warning with the reasoning

query-in-loop

Queries in loops

One round trip per item, the N+1. It knows Prisma batches findUnique, so those pass.

Warning with the reasoning

on every fix

One index, not two

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

Weighed against your reads and writes

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]

  1. How often this query ran

    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.

  2. Rows written against reads

    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.

  3. What you declared

    Mark a table writeHeavy in plinth.config.json and, without synced stats, its index suggestions drop a level.

  4. Call sites in your code

    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.

plinth.config.json, field by field

One file next to your code. Scroll to fill it in.

plinth.config.json
{  "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.

  1. rows: How big the table is. Rough is fine.
  2. distinct: Values per column. Few values, weak index.
  3. writeHeavy: Your guess: writes dominate this table.
  4. stats: The two weeks the counters cover.
  5. traffic: Measured: ~25k rows written an hour.
  6. statements: How often each query ran. No values kept.
  7. indexes: Real indexes. Never read: unused.
  8. planner: PostgreSQL's verdict on the suggested index.
  9. ignore: A team decision, always with a reason.
  10. smallTableRows: Smaller tables: Plinth stays quiet.
  11. largeTableRows: Larger tables: high confidence.
  12. dialect: Read from your ORM if left out.

Works with your stack

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.

Supported ORMs and databases
ORM PostgreSQL MySQL
Prisma @@index + migration.sql
Drizzle index().on() + drizzle-kit
TypeORM @Index + migration class

Plinth vs. AI review

Your AI reviewer has never seen your table sizes.

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.

Plinth compared with AI code review
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.

Pricing

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.

Free

$0 forever

For every developer, on every project.

  • VS Code extension
  • All rules, all three ORMs, PostgreSQL and MySQL
  • One-click index fixes, with the migration
  • Runs locally, no account
Install for VS Code

Teams

Coming soon

$15 per seat / month

$12 per seat a month billed annually. Volume pricing available.

  • Everything in Pro, for every seat
  • Table sizes and ignores shared by the whole team
  • Dashboard of every slow query caught
  • Compute cost report: findings ranked by the database time they spend, from pg_stat_statements
  • GitLab and Bitbucket
Join early access

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.

Find the slow query before your users do.

Install Plinth and open a file that queries your database. Findings appear on the line.

Install for VS Code