DEV Community

Bhandi MANOJ KUMAR
Bhandi MANOJ KUMAR

Posted on • Originally published at Medium

Your Article Title

Stop Exposing Your Database Secrets: A Prisma Exception Filter That Actually Makes Sense
Remember that time your frontend colleague pinged you at 11 PM asking "Bro, what is P2002?"
Turns out, Prisma's default error messages are about as helpful as asking ChatGPT to fix your production bug at 3 AM.
The Problem: Prisma Speaks Database, Users Speak Human
What Prisma returns by default:

{
 "statusCode": 500,
 "message": "PrismaClientKnownRequestError: Invalid `prisma.user.create()` invocation in /src/users/users.service.ts:42:38…",
 "error": "Internal Server Error"
}
Enter fullscreen mode Exit fullscreen mode

What your frontend dev sees: "Bc, database error hai. Fix kar." 🤦‍♂️
What users see: "Sorry, something went wrong. Please try again later." (Translation: We have no idea what broke, but it's definitely your fault for existing.)
What actually happened: Someone tried registering with an email that already exists. That's it. Simple. But Prisma decided to write a 10-line essay about it.
What We Actually Need
Clean. Simple. Actionable.

{
  "statusCode": 409,
  "message": "A record with this email already exists",
  "error": "Conflict",
  "timestamp": "2024-12-19T14:30:00.000Z",
  "path": "/auth/register"
}
Enter fullscreen mode Exit fullscreen mode

Frontend dev: "Perfect, I'll show 'Email already registered. Try logging in.'"
 User: "Oh right, I already have an account."
 You: Finally sleeping before midnight. 🎉
Understanding Prisma Error Codes (The Cheat Sheet)
Prisma error codes are like Aadhaar numbers - looks random, but there's a system:

Full prisma error reference (Bookmark this)
Building the Filter (The Fun Part)
Create prisma-exception.filter.ts (or whatever you want to name it, I'm not your tech lead):

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';

@Catch(
  Prisma.PrismaClientKnownRequestError,
  Prisma.PrismaClientInitializationError
)
export class PrismaExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(PrismaExceptionFilter.name);

  catch(
    exception: Prisma.PrismaClientKnownRequestError | Prisma.PrismaClientInitializationError,
    host: ArgumentsHost
  ): void {
    this.logger.error(exception.message);
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    // Log for your midnight debugging sessions
    this.logger.error(
      `Prisma Error: ${exception.message}`,
      exception.stack
    );

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'An unexpected database error occurred';

    // Check if it's a known request error (has error code)
    if ('code' in exception) {
      // The magic switch case (copy-paste friendly, you're welcome)
      switch (exception.code) {
        case 'P2002':
          status = HttpStatus.CONFLICT;
          const target = exception.meta?.target as string[];
          message = `A record with this ${target?.[0] || 'field'} already exists`;
          break;

        case 'P2025':
          const isDeleteOperation = 
            request.method === 'DELETE' || 
            exception.message.toLowerCase().includes('delete');

          if (isDeleteOperation) {
            // Idempotent delete - treat as success
            status = HttpStatus.OK;
            message = 'Resource successfully removed';

            this.logger.log(
              `P2025 on DELETE treated as success: ${request.url}`
            );
          } else {
            // Find/update operation - genuine not found
            status = HttpStatus.NOT_FOUND;
            const modelName = (exception.meta as any)?.modelName;
            message = modelName 
              ? `${modelName} not found`
              : 'The requested resource was not found';

            this.logger.warn(
              `P2025 NOT_FOUND: ${request.url} - ${modelName || 'Unknown model'}`
            );
          }
          break;

        case 'P2003':
          status = HttpStatus.BAD_REQUEST;
          message = 'Cannot perform this action due to related records. Please remove related items first';
          break;

        case 'P2011':
          status = HttpStatus.BAD_REQUEST;
          message = 'Required field is missing';
          break;

        case 'P2014':
          status = HttpStatus.CONFLICT;
          message = 'The required relation does not exist or the records are incompatible';
          break;

        case 'P2021':
          status = HttpStatus.INTERNAL_SERVER_ERROR;
          message = 'Database schema error. Please contact support.';
          this.logger.error('CRITICAL: Database table missing. Check migrations!');
          break;

        case 'P1002':
          status = HttpStatus.SERVICE_UNAVAILABLE;
          message = 'Database connection failed. Please try again later';
          break;

        case 'P1003':
          status = HttpStatus.INTERNAL_SERVER_ERROR;
          message = 'Database not found. Tenant may not be provisioned correctly';
          break;

        case 'P1004':
          status = HttpStatus.INTERNAL_SERVER_ERROR;
          message = 'Database access denied. Contact support';
          break;

        case 'P1013':
          status = HttpStatus.INTERNAL_SERVER_ERROR;
          message = 'Invalid database configuration. Contact support';
          break;

        case 'P4024':
          status = HttpStatus.GATEWAY_TIMEOUT;
          message = 'Database request timed out. Please try again';
          break;

        default:
          status = HttpStatus.INTERNAL_SERVER_ERROR;
          message = 'A database error occurred. Please contact support';
      }
    } else {
      // It's an initialization error (connection issues, missing tables, etc.)
      if (exception.message.includes('does not exist')) {
        message = 'Database configuration error. Please contact support.';
        this.logger.error('CRITICAL: Database schema issue detected!');
      } else if (exception.message.includes('timed out')) {
        status = HttpStatus.SERVICE_UNAVAILABLE;
        message = 'Database is temporarily unavailable. Please try again.';
      } else {
        message = 'Service temporarily unavailable. We\'re working on it.';
      }
    }

    response.code(status).send({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      message,
      error: this.getErrorName(status),
    });
  }

  private getErrorName(status: number): string {
    const errorMap: Record<number, string> = {
      [HttpStatus.BAD_REQUEST]: 'Bad Request',
      [HttpStatus.NOT_FOUND]: 'Not Found',
      [HttpStatus.CONFLICT]: 'Conflict',
      [HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
      [HttpStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable',
      [HttpStatus.GATEWAY_TIMEOUT]: 'Gateway Timeout',
    };
    return errorMap[status] || 'Error';
  }
}
Enter fullscreen mode Exit fullscreen mode

Pro tip: That response.code(status).send() is for Fastify. If you're using Express (why tho?), replace it with response.status(status).json().
Step 2: Register It Globally
In your main.ts:
Done. Every Prisma error in your entire app now goes through this filter.
No more "P2002" showing up in prod and your PM asking "Is this a bug or a feature?" (It's a bug, boss. It's always a bug.)
Real-World Scenarios (The Good Stuff)
Scenario 1: User Registration with Duplicate Email
Without filter:

{
  "statusCode": 500,
  "message": "PrismaClientKnownRequestError..."
}
Enter fullscreen mode Exit fullscreen mode

Frontend dev: "What do I show the user?"
 You: "Idk, show 'something went wrong'?"
 User: closes tab, never comes back
With filter:

{
  "statusCode": 409,
  "message": "A record with this email already exists"
}
Enter fullscreen mode Exit fullscreen mode

Frontend dev: "Perfect, I'll show 'Email already registered. Try logging in.'"
 User: "Oh right, I already have an account."
Scenario 2: Deleting a Record with Dependencies
Without filter:

{
  "statusCode": 500,
  "message": "Foreign key constraint failed..."
}
Enter fullscreen mode Exit fullscreen mode

User clicks delete.
 Error appears.
 User clicks delete again.
 Error appears.
 User rage-quits.

{
  "statusCode": 400,
  "message": "Cannot perform this action due to related records. Please remove related items first."
}
Enter fullscreen mode Exit fullscreen mode

User: "Oh, I need to delete the dependent records first. Makes sense."
Scenario 3: Multi-Tenant SaaS (My Use Case)
I'm building a poultry farm management SaaS (yes, chickens 🐔). Each tenant gets their own database schema.
Problem: Sometimes tenant databases aren't provisioned yet (don't ask why, infra team is "working on it").
Without filter:

{
  "statusCode": 500,
  "message": "Database `tenant_abc` does not exist..."
}
Enter fullscreen mode Exit fullscreen mode

Client: "Your app is broken."
 Me: "No, your database doesn't exist yet."
 Client: "Same thing."
With filter:

{
  "statusCode": 500,
  "message": "Database not found. Tenant may not be provisioned correctly."
}
Enter fullscreen mode Exit fullscreen mode

Client opens support ticket.
 Infra team provisions database.
 Problem solved without me getting blamed. 🎯
Scenario 4: The "Table Doesn't Exist" Horror Story
This actually happened to me last week. Deployed to staging, everything worked fine. Deployed to prod, and…

{
  "statusCode": 500,
  "timestamp": "2025-12-03T16:34:27.567Z",
  "path": "/api/auth/login",
  "method": "POST",
  "message": "\nInvalid `this.prisma.user.findUnique()` invocation in\n/app/apps/api/src/modules/users/users.service.ts:56:29\n\n 53 }\n 54 \n 55 async findByEmail(email: string) {\n→ 56 return this.prisma.user.findUnique(\nThe table `public.users` does not exist in the current database.",
  "error": "PrismaClientKnownRequestError"
}
Enter fullscreen mode Exit fullscreen mode

Users trying to login: Can't login.
 My PM on Slack: "Is login broken?"
 Me, sweating: "Give me 5 minutes."
 Actual problem: Forgot to run migrations on prod. Classic. 🤦‍♂️
What users saw: That massive error message exposing my file structure (/app/apps/api/src/modules/users/users.service.ts:56:29) and database schema details.
What they SHOULD have seen with our filter:

{
  "statusCode": 500,
  "timestamp": "2025-12-03T16:34:27.567Z",
  "path": "/api/auth/login",
  "method": "POST",
  "message": "Service temporarily unavailable. We're working on it.",
  "error": "Internal Server Error"
}
Enter fullscreen mode Exit fullscreen mode

Clean. Professional. Doesn't expose your entire codebase to the internet.
What I learned:
Always run migrations before deploying (duh)
Test your exception filters with REAL errors (not just unit tests)
Log everything (Future You at 3 AM will be grateful)
Never expose file paths in production (security 101)
Have a rollback plan (for when you inevitably forget migrations again)

Scenario 5: The "Ghost Delete" Problem
Here's a fun one that bit me while building an article CMS:

{
  "statusCode": 500,
  "message": "Invalid `prisma.articleSectionContent.delete()` invocation...\n\nAn operation failed because it depends on one or more records that were required but not found. Record to delete does not exist.",
  "error": "PrismaClientKnownRequestError",
  "code": "P2025"
}
Enter fullscreen mode Exit fullscreen mode

What happened:
User edited an article section
Frontend sent delete request for content that was already removed
Prisma: "Bro, I can't delete what doesn't exist"
API: throws 500 error
User: "Your app is broken"

The problem: This is actually NOT an error from the user's perspective. They wanted it deleted. It's deleted (by someone/something else, but still deleted). Mission accomplished, right?
But Prisma treats this as a failure. And if you're not handling P2025 properly, your users see a scary 500 error for a successful operation.
The Right Way to Handle P2025
The philosophy: Is "record not found" an error? Depends on context.
Scenario A - User searching for data:
GET /articles/123
Response: 404 Not Found ❌
Message: "Article not found"
This IS an error. User expected to find something, didn't.
Scenario B- User deleting data:
DELETE /articles/123
Response: 200 OK ✅
Message: "Article deleted"

This is NOT an error. User wanted it gone, it's gone. Success!

**Your exception filter should be smart enough to know the difference.** That's why our filter checks the HTTP method and treats DELETE operations as successful even when P2025 occurs.

---

### Why "Ghost Deletes" Happen

**Common scenarios:**

1. **Race conditions:**
   - User clicks delete twice (impatient clicking)
   - Two requests hit server simultaneously
   - First one deletes, second one fails

2. **Stale frontend state:**
   - User has old data cached
   - Tries to delete something that was deleted 5 minutes ago
   - Backend: "What are you even talking about?"

3. **Cascade deletes:**
   - You delete a parent record
   - Prisma cascades and deletes children
   - Your code tries to manually delete a child
   - Too late, it's already gone

4. **Multi-user editing:**
   - User A deletes section 3
   - User B (still on old page) tries to delete section 3
   - Boom, P2025

---

### War Story: The Double-Click Disaster

**2 AM. Production is down.**

Users are mass-reporting: "Can't delete images from articles!"

**Me, frantically checking logs:**

P2025: Record to delete does not exist
P2025: Record to delete does not exist
P2025: Record to delete does not exist

Enter fullscreen mode Exit fullscreen mode

Hundreds of them.
What was happening:
Our upload component had a loading state bug
Users clicked "Delete" once
Nothing happened (UI bug, no loading indicator)
Users clicked again
First request completes → deletes image
Second request arrives → P2025 error → 500 to user
Users panic → click delete again → more 500 errors
Snowball effect

The fix:
Frontend fix: Disable button on click, show loading
Backend fix: Make deletes idempotent (return 200 for already deleted)

Time to fix: Frontend PR - 2 hours. Backend filter update - 10 minutes.
Lesson: Good error handling isn't just "nice to have." It prevents production meltdowns caused by UI bugs you didn't even know existed.
Testing Your Filter (Don't Deploy Without This)
Test 1: Unique Constraint Violation

// Create a user
await prisma.user.create({
  data: { email: 'test@example.com', name: 'Test User' }
});
Enter fullscreen mode Exit fullscreen mode
// Try creating duplicate
await prisma.user.create({
  data: { email: 'test@example.com', name: 'Duplicate Guy' }
});
Enter fullscreen mode Exit fullscreen mode

Expected: 409 Conflict with message "A record with this email already exists"
If you get 500 Internal Server Error, your filter isn't registered properly. Check main.ts.
Test 2: Idempotent Delete

// Delete a record
const article = await prisma.article.create({
  data: { title: 'Test Article', content: 'Test' }
});

await prisma.article.delete({
  where: { id: article.id }
});

// Try deleting again (should NOT throw error)
await prisma.article.delete({
  where: { id: article.id }
});
Enter fullscreen mode Exit fullscreen mode

Expected with filter: 200 OK with message "Resource successfully removed"
Without filter: 500 Internal Server Error (bad UX)
Production Tips (Learn From My Mistakes)

  1. Don't Leak Database Info ❌ "Table 'users' doesn't exist"  ✅ "Database error occurred. Contact support." Why? Because hackers love free intel. Don't help them.
  2. Log Everything (For Future You) this.logger.error( Prisma Error [${exception.code}]: ${exception.message}, exception.stack ); At 3 AM, when prod is down, you'll thank Past You for these logs.
  3. Monitor Error Rates If you're seeing P2002 errors spike: Users are trying to create duplicates (UX issue - maybe add "check availability" button)

If you're seeing P2025 errors spike:
Users are trying to access deleted records (cache issue - clear your Redis, bro)

If you're seeing P4024 errors:
Your queries are too slow (time to add indexes or hire a DBA)

  1. Make Deletes Idempotent Bad approach:
// Throws error if already deleted
await prisma.article.delete({ where: { id } });
Enter fullscreen mode Exit fullscreen mode

Good approach:

// Check first, then delete
const article = await prisma.article.findUnique({ where: { id } });
if (article) {
  await prisma.article.delete({ where: { id } });
}
return { success: true };
Enter fullscreen mode Exit fullscreen mode

Best approach:

// Use deleteMany (never throws P2025)
await prisma.article.deleteMany({ where: { id } });
// Returns { count: 0 } if already deleted, { count: 1 } if deleted
return { success: true };
Enter fullscreen mode Exit fullscreen mode

Why deleteMany is magic:
Never throws P2025 (just returns count: 0)
Idempotent by design
Less code in your exception filter

Tradeoff: Can't use relation deletes easily with deleteMany, so pick based on your needs.

  1. Set Up Alerts for Critical Errors When database tables are missing or connections fail in prod, you want to know IMMEDIATELY:
case 'P2021':
case 'P1003':
  status = HttpStatus.INTERNAL_SERVER_ERROR;
  message = 'Database schema error. Please contact support.';

  // Alert ops team (Slack, Discord, PagerDuty, whatever)
  await this.sendCriticalAlert({
    error: 'Database schema issue',
    code: exception.code,
    path: request.url,
    message: exception.message
  });
  break;
Enter fullscreen mode Exit fullscreen mode

Trust me, you want this. Finding out about prod issues from your users is embarrassing. Finding out from your monitoring system is professional.
Hall of Shame: Common Deployment Mistakes
Things we've all done:
Pushed to prod without running migrations
Tested on localhost (worked fine), broke in prod (database was different)
Forgot to add new environment variables
Deployed at 5 PM on Friday (rookie mistake)
Hardcoded database credentials (security team wants to talk)

The fix for all of these: A proper CI/CD pipeline. But until your company approves that budget request, this exception filter will save your ass.
Wrapping Up
What you just built: User-friendly error messages
 Proper HTTP status codes
 No more exposed database internals
 Context-aware error handling (DELETE vs GET)
 Idempotent operations
 Happy frontend developers
 Happy users
 Happy you (no more 11 PM debug calls)
Real talk: This filter won't prevent deployment mistakes (looking at you, P2021). But it WILL prevent those mistakes from exposing your entire codebase to users and hackers.
Defense in depth, baby.
Error happens → Filter catches it → User sees clean message → You fix it → Everyone moves on.
Without filter? Error happens → User sees your file structure → They screenshot it → It ends up on Twitter → Your security team has a breakdown → You're in the incident report → Not a good day.
I know which timeline I prefer.

About Me
I'm Manoj, a full-stack developer building scalable multi-tenant SaaS applications with NestJS, Prisma, Next.js, and way too much chai ☕.
Currently working on a poultry farm management system that handles real-time data for multiple farms. Yes, seriously. And yes, it's actually interesting when you get past the "wait, chickens?" reaction.
I love architecture, optimization, and the real-world problems we pretend don't exist until 3 AM on a Saturday when production is on fire.
If this helped, drop a ❤️ below. If you have questions or your own 3 AM war stories, drop them in the comments - I reply to everyone (usually with more chai in hand).
Connect: linkedin | github
Related Readings
NestJs Exception Filters (Official Docs)
Never Expose Stack Traces in Prod

Top comments (0)