Gerson

Gerson

Passionate developer specializing in web development, cloud architecture, and system design.

TypeScriptReactNext.jsPythonFastAPISQLNode.jsAWS

Type-Safe Database Queries with Prisma and PostgreSQL

Master Prisma ORM for type-safe database access in TypeScript. Learn schema design, migrations, queries, and production best practices.

Gerson
Database and code visualization

Prisma has revolutionized how we interact with databases in TypeScript applications. Unlike traditional ORMs that use classes and inheritance, Prisma generates a type-safe client based on your data model, providing an intuitive API tailored to your specific schema.

In this guide, we'll set up Prisma with PostgreSQL and explore best practices for building production applications.

Why Prisma?

Prisma provides three core components:

  • Prisma Client - Auto-generated, type-safe query builder
  • Prisma Migrate - Declarative schema migrations
  • Prisma Studio - Visual database browser

The type safety is comprehensive - even when selecting subsets of fields, TypeScript knows exactly what shape your data will have.

Getting Started

Install Prisma and initialize your project:

Terminal

# Install dependencies
npm install prisma @prisma/client
npm install -D typescript @types/node

# Initialize Prisma with PostgreSQL
npx prisma init --datasource-provider postgresql

Configure your database connection in .env:

.env

DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

Designing Your Schema

The Prisma schema is the single source of truth for your database structure:

prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
  profile   Profile?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([email])
}

model Profile {
  id     String  @id @default(cuid())
  bio    String?
  avatar String?
  userId String  @unique
  user   User    @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Post {
  id          String     @id @default(cuid())
  title       String
  content     String?
  published   Boolean    @default(false)
  authorId    String
  author      User       @relation(fields: [authorId], references: [id])
  categories  Category[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([authorId])
  @@index([published])
}

model Category {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

enum Role {
  USER
  ADMIN
  MODERATOR
}

Best Practice: Use cuid() for IDs instead of auto-increment. CUIDs are URL-safe, don't reveal record counts, and work well in distributed systems.

Database schema visualization
Prisma Schema provides a declarative way to define your database structure

Migrations

Prisma Migrate handles database schema changes:

Terminal

# Create and apply migration
npx prisma migrate dev --name init

# Generate Prisma Client
npx prisma generate

For existing databases, baseline your migrations:

Terminal

# Generate SQL from current schema diff
npx prisma migrate diff \
  --from-empty \
  --to-schema-datamodel prisma/schema.prisma \
  --script > prisma/migrations/0_init/migration.sql

# Mark as applied
npx prisma migrate resolve --applied 0_init

Type-Safe Queries

The generated Prisma Client provides full type safety:

lib/prisma.ts

import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

Basic CRUD Operations

Create

// Create a user with profile
const user = await prisma.user.create({
  data: {
    email: 'alice@example.com',
    name: 'Alice',
    profile: {
      create: {
        bio: 'Software Engineer',
      },
    },
  },
  include: {
    profile: true,
  },
});
// TypeScript knows: user.profile is Profile | null

Read with Relations

// Get user with posts and categories
const userWithPosts = await prisma.user.findUnique({
  where: { email: 'alice@example.com' },
  include: {
    posts: {
      where: { published: true },
      include: { categories: true },
      orderBy: { createdAt: 'desc' },
      take: 10,
    },
  },
});

// Full type safety - posts is typed correctly
userWithPosts?.posts.forEach(post => {
  console.log(post.title, post.categories);
});

Update

// Update user role
const updatedUser = await prisma.user.update({
  where: { id: userId },
  data: {
    role: 'ADMIN',
    updatedAt: new Date(),
  },
});

Delete with Cascade

// Delete user (profile cascades due to onDelete: Cascade)
await prisma.user.delete({
  where: { id: userId },
});

Advanced Patterns

Transactions

Interactive Transaction

const result = await prisma.$transaction(async (tx) => {
  // Decrement sender balance
  const sender = await tx.account.update({
    where: { id: senderId },
    data: { balance: { decrement: amount } },
  });

  if (sender.balance < 0) {
    throw new Error('Insufficient funds');
  }

  // Increment receiver balance
  const receiver = await tx.account.update({
    where: { id: receiverId },
    data: { balance: { increment: amount } },
  });

  return { sender, receiver };
});

Raw SQL with TypedSQL

Prisma's TypedSQL lets you write raw SQL with full type safety:

prisma/sql/getUserStats.sql

-- @param {String} $1:userId
SELECT
  u.id,
  u.name,
  COUNT(p.id) as "postCount",
  COUNT(CASE WHEN p.published THEN 1 END) as "publishedCount"
FROM "User" u
LEFT JOIN "Post" p ON p."authorId" = u.id
WHERE u.id = $1
GROUP BY u.id;

Usage

import { getUserStats } from '@prisma/client/sql';

const stats = await prisma.$queryRawTyped(getUserStats(userId));
// stats is fully typed based on SQL return columns

New in Prisma 6: TypedSQL generates TypeScript types from your .sql files, giving you raw SQL power with ORM-level type safety.

Production Best Practices

Connection Pooling

For serverless environments, use connection pooling:

Prisma Accelerate

import { PrismaClient } from '@prisma/client';
import { withAccelerate } from '@prisma/extension-accelerate';

const prisma = new PrismaClient().$extends(withAccelerate());

// Queries can now use caching
const users = await prisma.user.findMany({
  cacheStrategy: {
    ttl: 60, // Cache for 60 seconds
  },
});

Logging

Query Logging

const prisma = new PrismaClient({
  log: [
    { emit: 'event', level: 'query' },
    { emit: 'stdout', level: 'error' },
    { emit: 'stdout', level: 'warn' },
  ],
});

prisma.$on('query', (e) => {
  console.log(`Query: ${e.query}`);
  console.log(`Duration: ${e.duration}ms`);
});

Conclusion

Prisma transforms database development in TypeScript. With its declarative schema, automatic migrations, and type-safe client, you can focus on building features instead of wrestling with SQL and type definitions.

Start with the schema, let Prisma generate the client, and enjoy catching database errors at compile time instead of runtime.