Why I Stopped Using Prisma After It Generated a 47-Table JOIN Behind My Back
The staging demo was in nine hours. I hit refresh. The spinner ran for fourteen full seconds — and then the page loaded.
Why I Stopped Using Prisma After It Generated a 47-Table JOIN Behind My Back

The staging demo was in nine hours. I hit refresh. The spinner ran for fourteen full seconds — and then the page loaded.
I sat there staring at a profile page. A single user profile. I had written maybe three lines of code to fetch it. My stomach dropped before my brain even processed what had happened.
I opened the query logs expecting something small, maybe a missed index. What I found instead was 200 lines of raw SQL — a JOIN chain so deep it touched 47 tables for one user fetch. Prisma had built that in silence, with a smile, and handed it to my database like a grenade.
That was the night I stopped trusting the magic.
The Schema That Grew Quietly
The project was an internal analytics tool — users, teams, projects, roles, activity logs. Standard stuff. I had been building it for about eighteen months, and the Prisma schema had grown the way schemas always do: organically, one model at a time, always feeling manageable in the moment.
We had 23 models at that point. Each one connected to two or three others. On paper it looked clean. In reality, the relationship graph was a spiderweb I had stopped thinking about.
Everything ran beautifully on seed data. Local development was fast and frictionless. Prisma felt like a superpower — autocomplete, type safety, readable queries. I was shipping fast and proud of it.
Then staging got loaded with ten thousand real users, hundreds of projects, and thousands of nested records. That is when the graph revealed itself.
The Query That Lit The Fire
Here is the exact code that caused the disaster. Read it slowly, because it looks completely innocent:
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
teams: {
include: {
projects: {
include: {
tasks: true,
members: true,
},
},
},
},
},
})
In my head: a user, their teams, the projects under those teams, some tasks and members. Maybe five tables. Six at most.
Prisma’s head: 47 tables.
The reason is mechanical and brutal. Prisma resolves include chains by following every relation defined in your schema — all the way down. My projects had a members relation. Those members linked to roles. Those roles had permissions. Those permissions had scopes. I had wired all of that up months earlier and completely forgotten it existed.
Prisma did not forget. Prisma never forgets.
What The Logs Actually Showed
I enabled query logging and ran it again. The output scrolled for longer than felt right. Here is a condensed version of what it looked like:
SELECT u.*, t.*, p.*, tk.*, m.*, r.*, rp.*, s.*, ...
FROM users u
LEFT JOIN _UserToTeam utt ON utt.A = u.id
LEFT JOIN teams t ON t.id = utt.B
LEFT JOIN _TeamToProject ttp ON ttp.A = t.id
LEFT JOIN projects p ON p.id = ttp.B
LEFT JOIN tasks tk ON tk.projectId = p.id
LEFT JOIN _ProjectMembers pm ON pm.A = p.id
LEFT JOIN users m ON m.id = pm.B
LEFT JOIN _UserToRole utr ON utr.A = m.id
LEFT JOIN roles r ON r.id = utr.B
-- 38 more joins followed this
Nobody sat down and wrote that query. It assembled itself from three lines of JavaScript and eighteen months of schema decisions I had forgotten making.
Why The Data Graph Explodes
Here is what my actual data graph looked like once I drew it on paper. I had never visualized it before that night.
User
└── Teams (via _UserToTeam)
└── Projects (via _TeamToProject)
├── Tasks
│ └── Assignees (User)
│ └── Roles
│ └── Permissions
│ └── Scopes
└── Members (User)
└── Roles
└── Permissions
└── Scopes
See the problem? The Roles -> Permissions -> Scopes chain appears twice — once under Tasks and once under Members — because two different paths in the graph both resolve to the same sub-tree. Prisma joins each path independently. So you pay for it twice.
With even modest depth and branching, you hit 20, 30, 47 tables without realizing it. No warning fires. No error surfaces. The query just silently grows.
The Numbers That Made Me Sick
Here is the benchmark table I ran that night, after I calmed down enough to test properly:
| Approach | Query Time | Tables Touched |
| ------------------------- | ---------: | -------------: |
| Deep `include` (original) | 14,200 ms | 47 |
| Selective `select` | 890 ms | 8 |
| Three focused queries | 310 ms | 6 |
| Raw SQL | 95 ms | 4 |
One hundred and fifty times slower. For a profile page. Because I used include without thinking about where my graph ended.
What Actually Fixed It
The first fix was switching from include to select. The difference sounds small. It is not.
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
teams: {
select: {
id: true,
name: true,
},
},
},
})
select forces you to be explicit about every field you want. You cannot accidentally pull in six levels of nesting because you have to name everything. More verbose, yes. Ten times faster on real data, also yes.
The second fix was breaking one massive query into smaller, focused ones:
const user = await prisma.user.findUnique({ where: { id: userId } })
const teams = await prisma.team.findMany({ where: { members: { some: { id: userId } } } })
const projects = await prisma.project.findMany({
where: { teamId: { in: teams.map(t => t.id) } },
select: { id: true },
})
Three queries. Under thirty milliseconds total. The single include monster: fourteen seconds. That comparison alone should be alarming to every developer using Prisma on a maturing codebase.
For the most read-heavy endpoints, the third fix was dropping to raw SQL entirely:
const rows = await prisma.$queryRaw`
SELECT u.id, u.name, t.id as team_id, t.name as team_name
FROM users u
JOIN _UserToTeam utt ON utt.A = u.id
JOIN teams t ON t.id = utt.B
WHERE u.id = ${userId}
`
Raw SQL is not a defeat. It is a decision. Prisma gives you the escape hatch for exactly this reason — use it when the abstraction has stopped serving you.
The Honest Part Nobody Says Out Loud
Prisma is not broken. I want to be clear about that.
It did exactly what I told it to do. The mistake was mine — I used include like a convenience shortcut and never stopped to think about what my schema graph actually looked like at depth. I tested with toy data and trusted the abstraction completely.
The other trap I had fallen into was N+1 anxiety. Developers reach for include because the loop-query alternative looks obviously wrong:
for (const team of teams) {
const projects = await prisma.project.findMany({
where: { teamId: team.id },
})
}
So they switch to include thinking it is the safe, smart path. Sometimes it is. But sometimes you trade a hundred small queries for one query so heavy it performs worse than all of them combined.
What I Do Differently Now
Before writing any include that goes deeper than one level, I ask myself one question: do I actually need all of this data, right now, for this specific request?
I also run DEBUG="prisma:query" during development on any new endpoint touching more than two or three models. The raw SQL output is ugly and uncomfortable to read. That discomfort is the point. It forces you to confront what you are actually asking your database to do.
And I visualize the relationship graph for any model I am querying deeply. Not in code. On paper, or in a text tree like the one above. Until you see the branches spreading, you have no real sense of the cost.
메타데이터
- post_id
- d4591a70081c
- slug
- why-i-stopped-using-prisma-after-it-generated-a-47-table-join-behind-my-back-d4591a70081c
- url
- https://medium.com/@maahisoft20/why-i-stopped-using-prisma-after-it-generated-a-47-table-join-behind-my-back-d4591a70081c
- canonical_url
- https://medium.com/@maahisoft20/why-i-stopped-using-prisma-after-it-generated-a-47-table-join-behind-my-back-d4591a70081c
- author_url
- https://medium.com/@maahisoft20
- status
- ok
- fetched_at
- 2026-06-15 20:49:13