Skip to content

Advanced Prisma

The basics get you 80% of the way The other 20% is where you screw up if you don't know how relations work at scale, why aggregation queries can tank your server memory, when to drop down to raw SQL because the ORM abstraction is costing you 10x performance, and how to secure sensitive data at the ORM layer before it ever reaches the database

Relations in depth

One-to-One with optional relation

model User {
    id      String   @id @default(uuid())
    profile Profile?  // Optional - user may not have profile
}

model Profile {
    id     String @id @default(uuid())
    userId String @unique @map("user_id")
    user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
    // onDelete: Cascade - deletes profile when user is deleted
    bio    String
    avatar String?
}

The @unique on userId enforces one-to-one: no two profiles can reference the same user. The onDelete: Cascade means deleting a user automatically deletes their profile - use onDelete: Restrict if you want the database to prevent deleting users with active profiles

One-to-Many with filtered includes

// Get user with only their published posts (filtered include)
const user = await prisma.user.findUnique({
    where: { id: userId },
    include: {
        posts: {
            where: { published: true },
            orderBy: { createdAt: 'desc' },
            take: 10
        }
    }
});

Many-to-Many with additional fields on join table

Implicit many-to-many works for simple cases. When you need extra data on the relationship (like assignedAt or role in a team membership), you need an explicit join model:

model User {
    id        String         @id @default(uuid())
    teams     TeamMembership[]
}

model Team {
    id        String         @id @default(uuid())
    members   TeamMembership[]
}

model TeamMembership {
    userId    String   @map("user_id")
    teamId    String   @map("team_id")
    user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
    team      Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)
    role      String   @default("member")  // Extra field on the relationship
    joinedAt  DateTime @default(now()) @map("joined_at")

    @@id([userId, teamId])
    @@map("team_memberships")
}
// Query with explicit join model
const teamsForUser = await prisma.user.findUnique({
    where: { id: userId },
    include: {
        teams: {
            include: {
                team: true  // Include the full team data
            },
            where: {
                role: 'admin'  // Filter by membership role
            }
        }
    }
});

Aggregations and group by

Prisma provides aggregation functions that map to SQL's COUNT, SUM, AVG, MIN, MAX:

// Aggregate all records
const stats = await prisma.order.aggregate({
    _count: { id: true },
    _sum: { totalAmount: true },
    _avg: { totalAmount: true },
    _min: { totalAmount: true },
    _max: { totalAmount: true },
    where: { status: 'delivered' }
});
// stats._count.id = 1523 (total delivered orders)
// stats._sum.totalAmount = 452890.50

// Group by with aggregation
const revenueByStatus = await prisma.order.groupBy({
    by: ['status'],
    _count: { id: true },
    _sum: { totalAmount: true },
    where: { createdAt: { gte: startDate } },
    orderBy: { _sum: { totalAmount: 'desc' } }
});
// [
//   { status: 'delivered', _count: { id: 1200 }, _sum: { totalAmount: 350000 } },
//   { status: 'pending', _count: { id: 300 }, _sum: { totalAmount: 95000 } }
// ]

Raw queries - when ORM isn't enough

Sometimes you need window functions, recursive CTEs, or complex joins that Prisma's query API doesn't support. That's when you drop to $queryRaw or $executeRaw:

// Raw SQL query with parameterization (safe - uses $1, $2 placeholders)
const users = await prisma.$queryRaw`
    SELECT
        id, email, username,
        COUNT(o.id) as order_count,
        SUM(o.total_amount) as total_spent,
        RANK() OVER (ORDER BY SUM(o.total_amount) DESC) as rank
    FROM users u
    LEFT JOIN orders o ON o.user_id = u.id
    WHERE u.is_active = ${true}
    GROUP BY u.id, u.email, u.username
    ORDER BY total_spent DESC
    LIMIT ${limit}
`;

// Raw execute for bulk operations
await prisma.$executeRaw`
    UPDATE orders
    SET status = 'archived'
    WHERE created_at < ${cutoffDate}
    AND status = 'delivered'
`;

WARNING: $queryRaw and $executeRaw accept template literal parameters safely (Prisma parameterizes them), but if you interpolate strings directly into the query with string concatenation, you reintroduce SQL injection:

// SAFE - template literal with parameter
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

// DANGEROUS - string interpolation
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);
// $queryRawUnsafe is vulnerable to SQL injection - avoid it

Middleware and hooks

Prisma middleware lets you intercept and modify queries before or after they execute. This is where you implement audit logging, field-level encryption, query monitoring, and access control checks

Audit logging middleware

const prisma = new PrismaClient();

// Middleware that logs all delete operations
prisma.$use(async (params, next) => {
    // params.model - the model name (e.g., 'User', 'Post')
    // params.action - the operation type (e.g., 'delete', 'update')

    if (params.action === 'delete' || params.action === 'deleteMany') {
        console.log(`[AUDIT] ${params.model} ${params.action} at ${new Date().toISOString()}`);

        // Log to audit table
        await prisma.auditLog.create({
            data: {
                model: params.model,
                action: params.action,
                args: JSON.stringify(params.args),
                userId: getCurrentUserId()  // From your auth context
            }
        });
    }

    const result = await next(params);
    return result;
});

Field-level encryption middleware

const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY;  // 32 bytes, hex-encoded

function encrypt(text) {
    const iv = crypto.randomBytes(16);
    const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), iv);
    let encrypted = cipher.update(text, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    return iv.toString('hex') + ':' + encrypted + ':' + cipher.getAuthTag().toString('hex');
}

function decrypt(text) {
    const parts = text.split(':');
    const iv = Buffer.from(parts.shift(), 'hex');
    const authTag = Buffer.from(parts.pop(), 'hex');
    const encrypted = parts.join(':');
    const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), iv);
    decipher.setAuthTag(authTag);
    let decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
}

// Middleware to encrypt/decrypt sensitive fields
prisma.$use(async (params, next) => {
    // Encrypt on create/update
    if (params.action === 'create' || params.action === 'update') {
        if (params.args.data?.ssn) {
            params.args.data.ssn = encrypt(params.args.data.ssn);
        }
        if (params.args.data?.creditCardNumber) {
            params.args.data.creditCardNumber = encrypt(params.args.data.creditCardNumber);
        }
    }

    // Decrypt on read
    if (params.action === 'findUnique' || params.action === 'findFirst' || params.action === 'findMany') {
        const result = await next(params);

        // Handle single result
        if (result && !Array.isArray(result)) {
            if (result.ssn) result.ssn = decrypt(result.ssn);
            if (result.creditCardNumber) result.creditCardNumber = decrypt(result.creditCardNumber);
        }

        // Handle array result
        if (Array.isArray(result)) {
            for (const item of result) {
                if (item.ssn) item.ssn = decrypt(item.ssn);
                if (item.creditCardNumber) item.creditCardNumber = decrypt(item.creditCardNumber);
            }
        }

        return result;
    }

    return next(params);
});

Request logging for performance monitoring

prisma.$use(async (params, next) => {
    const start = Date.now();
    const result = await next(params);
    const duration = Date.now() - start;

    if (duration > 100) {
        console.warn(`[SLOW QUERY] ${params.model}.${params.action} took ${duration}ms`);
        // Send to APM (Datadog, New Relic, Sentry):
        // apm.trace('prisma.query', { model: params.model, action: params.action, duration });
    }

    return result;
});

Pagination patterns

Offset pagination (simple but slow at high offsets)

const page = 1;
const pageSize = 20;

const users = await prisma.user.findMany({
    skip: (page - 1) * pageSize,
    take: pageSize,
    orderBy: { createdAt: 'desc' }
});
// First page - no cursor
const firstPage = await prisma.user.findMany({
    take: 20,
    orderBy: { id: 'asc' }  // Must use unique field for cursor
});

// Get the last item's ID as cursor
const cursor = firstPage[firstPage.length - 1].id;

// Next page - use cursor
const nextPage = await prisma.user.findMany({
    take: 20,
    skip: 1,  // Skip the cursor itself
    cursor: { id: cursor },
    orderBy: { id: 'asc' }
});

Cursor-based pagination is stable even when new records are inserted - offset pagination can skip or duplicate results when the underlying data changes between pages

Transaction patterns

// Sequential transaction - multiple operations, single commit
const [user, totalOrders] = await prisma.$transaction([
    prisma.user.update({
        where: { id: userId },
        data: { isActive: false }
    }),
    prisma.order.count({
        where: { userId, status: 'pending' }
    })
]);

// Interactive transaction - conditional logic within
const result = await prisma.$transaction(async (tx) => {
    // All operations use 'tx' instead of 'prisma'
    const account = await tx.account.findUnique({
        where: { id: accountId }
    });

    if (account.balance < amount) {
        throw new Error('Insufficient funds');
    }

    await tx.account.update({
        where: { id: accountId },
        data: { balance: { decrement: amount } }
    });

    await tx.transaction.create({
        data: {
            fromId: accountId,
            toId: targetAccountId,
            amount: amount,
            type: 'TRANSFER'
        }
    });
});

prerequisites

db_14_prisma_intro.md - you need to understand Prisma schema, migrations, and basic CRUD before the advanced patterns make sense. If you don't know how findMany works, the middleware section will look like alien technology


next → db_16_migrations.md