An ORM (Object-Relational Mapper) lets you work with your database using your programming language instead of SQL. It reduces boilerplate and prevents SQL injection. But ORMs also add abstraction — and abstraction can hide performance problems.

When to Use an ORM vs Raw SQL

Use an ORM when:

  • You do standard CRUD (create, read, update, delete)
  • You want type safety and IDE autocomplete
  • You want schema migrations integrated with your code
  • You are building fast and the query complexity is low

Use raw SQL when:

  • You need complex queries (window functions, CTEs, custom aggregations)
  • Performance is critical and you need to see exactly what runs
  • You need database-specific features the ORM does not expose
  • You are debugging a slow query

Most applications use both: ORM for 80% of queries, raw SQL for the complex 20%.

The Same Query in All Three

Fetch users with their recent orders, including order items:

Raw SQL:

SELECT u.id, u.name, u.email,
       o.id as order_id, o.total, o.status,
       oi.product_id, oi.quantity, oi.unit_price
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.created_at > NOW() - INTERVAL '30 days'
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE u.id = 42
ORDER BY o.created_at DESC;

Prisma v6 (TypeScript):

const user = await prisma.user.findUnique({
  where: { id: 42 },
  include: {
    orders: {
      where: {
        createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
      },
      orderBy: { createdAt: "desc" },
      include: {
        items: true,
      },
    },
  },
});

SQLAlchemy 2.x (Python):

from sqlalchemy.orm import selectinload
from datetime import datetime, timedelta, timezone

user = (
    session.query(User)
    .options(
        selectinload(User.orders).selectinload(Order.items)
    )
    .filter(
        User.id == 42,
        Order.created_at >= datetime.now(timezone.utc) - timedelta(days=30)
    )
    .first()
)

GORM v2 (Go):

var user User
db.Preload("Orders", "created_at > ?", time.Now().AddDate(0, 0, -30)).
   Preload("Orders.Items").
   First(&user, 42)

Prisma v6

Prisma is a TypeScript ORM with a schema-first workflow. Define your models in schema.prisma, run migrations, and get a fully typed client.

npm install prisma @prisma/client
npx prisma init
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  orders    Order[]
  createdAt DateTime @default(now())
}

model Order {
  id        Int         @id @default(autoincrement())
  userId    Int
  user      User        @relation(fields: [userId], references: [id])
  total     Decimal
  status    String      @default("pending")
  items     OrderItem[]
  createdAt DateTime    @default(now())
}

model OrderItem {
  id        Int    @id @default(autoincrement())
  orderId   Int
  order     Order  @relation(fields: [orderId], references: [id])
  productId Int
  quantity  Int
  unitPrice Decimal
}
npx prisma migrate dev --name init
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

// Create
const user = await prisma.user.create({
  data: { email: "alex@example.com", name: "Alex" },
});

// Raw SQL escape hatch
const result = await prisma.$queryRaw`
  SELECT * FROM users WHERE email ILIKE ${"%" + search + "%"}
`;

SQLAlchemy 2.x

Python’s most popular ORM. Supports both ORM-style and Core (SQL builder) style.

pip install sqlalchemy psycopg2-binary
from sqlalchemy import create_engine, String, Integer, ForeignKey, Numeric, DateTime, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
from datetime import datetime, timezone

engine = create_engine("postgresql+psycopg2://user:pass@localhost/mydb")

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id:         Mapped[int] = mapped_column(Integer, primary_key=True)
    email:      Mapped[str] = mapped_column(String, unique=True)
    name:       Mapped[str] = mapped_column(String)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
    orders:     Mapped[list["Order"]] = relationship(back_populates="user")

class Order(Base):
    __tablename__ = "orders"

    id:      Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    total:   Mapped[float] = mapped_column(Numeric(10, 2))
    user:    Mapped["User"] = relationship(back_populates="orders")

# Create tables
Base.metadata.create_all(engine)

# Use
with Session(engine) as session:
    user = User(email="alex@example.com", name="Alex")
    session.add(user)
    session.commit()

    found = session.query(User).filter(User.email == "alex@example.com").first()

    # Raw SQL
    result = session.execute(
        text("SELECT * FROM users WHERE email ILIKE :q"),
        {"q": "%alex%"}
    ).fetchall()

GORM v2 (Go)

go get gorm.io/gorm gorm.io/driver/postgres
package main

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "time"
)

type User struct {
    gorm.Model
    Email  string `gorm:"uniqueIndex"`
    Name   string
    Orders []Order
}

type Order struct {
    gorm.Model
    UserID    uint
    Total     float64
    Status    string `gorm:"default:pending"`
    CreatedAt time.Time
    Items     []OrderItem
}

type OrderItem struct {
    gorm.Model
    OrderID   uint
    ProductID uint
    Quantity  int
    UnitPrice float64
}

func main() {
    dsn := "host=localhost user=postgres dbname=mydb sslmode=disable"
    db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})

    db.AutoMigrate(&User{}, &Order{}, &OrderItem{})

    // Create
    db.Create(&User{Email: "alex@example.com", Name: "Alex"})

    // Find with preload
    var user User
    db.Preload("Orders").First(&user, 1)

    // Raw SQL
    var users []User
    db.Raw("SELECT * FROM users WHERE email LIKE ?", "%alex%").Scan(&users)
}

The N+1 Problem

The N+1 problem is the most common ORM performance issue. Loading 100 users then fetching each user’s orders separately = 101 queries.

// BAD: N+1 — 1 query for users + N queries for orders
const users = await prisma.user.findMany();
for (const user of users) {
  const orders = await prisma.order.findMany({ where: { userId: user.id } });
  // 1 extra query per user!
}

// GOOD: 1 query with include
const users = await prisma.user.findMany({
  include: { orders: true },
});

Always check your ORM’s query log in development to spot N+1 issues.

What’s Next?

You understand ORMs and when to use them. The final article is a complete reference cheat sheet for every database in this series.

Next: Database Tutorial #20: Database Cheat Sheet 2026