Skip to main content

Command Palette

Search for a command to run...

Demystifying Database Relations Using Drizzle ORM (with Real Examples)

Published
6 min read
Demystifying Database Relations Using Drizzle ORM (with Real Examples)
K

I am a passionate full-stack software developer in the MERN stack. With a deep love for coding and problem solving, I thrive in creating robust and scalable applications that deliver seamless user experiences.

As a developer, I'm driven by the desire to build elegant and efficient solutions that meet the unique requirements of each project. I enjoy working collaboratively, brainstorming ideas, and translating them into clean, maintainable code. From conceptualization to deployment, I take pride in delivering high-quality applications that exceed expectations.

Database relationships have always been that “scary” topic that makes junior developers sweat during technical interviews. But here’s the truth: they’re not complicated — they’re just poorly explained.

Today, I’m going to show you how Drizzle ORM makes database relations so intuitive that you’ll wonder why you ever struggled with them. We’ll build a real e-commerce system from scratch, and I promise - no boring theory without context.

Why Drizzle ORM Changed My Mind About ORMs

I used to be that developer who wrote raw SQL for everything. Just like the way I used to write pure CSS Tailwind CSS and other tooling that came along. “ORMs are bloated”, I’d say. “They hide what’s really happening”, I’d argue. Then I discovered Drizzle, and it completely changed my perspective.

Drizzle is different. It’s lightweight, Typescript-first, and — here’s the kicker — it doesn’t fight against SQL. It embraces it. You get full type safety without sacrificing control or performance.

The Three Database Relationships (The Right Way)

Let’s build a real e-commerce platform to understand these relationships. We’ll create users, products, orders, and categories - exactly what you’d build in production.

  1. One-to-Many: The Foundation

    Think about it: One user can place many orders, but each order belongs to exactly one user. That’s one-to-many.

     // schema.ts
     import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
     import { relations } from "drizzle-orm";
    
     export const users = pgTable('users', {
       id: serial('id').primaryKey(),
       name: text('name').notNull(),
       email: text('email').notNull().unique(),
       createdAt: timestamp('created_at').defaultNow(),
     });
    
     export const orders = pgTable('orders', {
       id: serial('id').primaryKey(),
       userId: integer('user_id').notNull().references(() => users.id),
       totalAmount: integer('total_amount').notNull(),
       status: text('status').notNull().default('pending'),
       createdAt: timestamp('created_at').defaultNow(),
     });
    
     // Define the relations
     export const usersRelations = relations(users, ({ many }) => ({
         orders: many(orders),
     }));
    
     export const ordersRelations = relations(orders, ({ one }) => ({
         user: one(users, {
             fields: [orders.userId],
             references: [users.id],
         }),
     }));
    

    Now here’s where Drizzle shines. Querying is beautifully simple:

     import { db } from "./db";
     import { users, orders } from "./schema";
    
     // Get a user with all their orders
     const userWithOrders = await db.query.users.findFirst({
         where: (users, { eq }) => eq(users.id, 1),
         with: {
             orders: true,
         }
     });
    
     console.log(userWithOrders);
     // {
     //   id: 1,
     //   name: "Kandy Coder",
     //   email: "kandy.coder@example.com",
     //   orders: [
     //     { id: 1, userId: 1, totalAmount: 5000, status: "completed" },
     //     { id: 2, userId: 1, totalAmount: 3500, status: "pending" }
     //   ]
     // }
    

    No SQL strings. No manual joins. Just pure Typescript with full autocomplete.

  2. Many-to-Many: The Game Changer

    Here’s where most tutorials lose people. Many-to-Many seems complex, but it’s actually logical.

    Consider: One product belong to many categories (a laptop is both “Electronics“ and “Office Supplies“), and one category contains many products. That’s many-to-many.

    The secret? A junction table (also called a join table).

     // still in our schema.ts file
     export const products = pgTable('products', {
       id: serial('id').primaryKey(),
       name: text('name').notNull(),
       price: integer('price').notNull(),
       description: text('description'),
       createdAt: timestamp('created_at').defaultNow(),
     });
    
     export const categories = pgTable('categories', {
       id: serial('id').primaryKey(),
       name: text('name').notNull().unique(),
       slug: text('slug').notNull().unique(),
     });
    
     // The junction table - this is the magic
     export const productsToCategories = pgTable('products_to_categories', {
         productId: integer('product_id').notNull().references(() => products.id),
         categoryId: integer('category_id').notNull().references(() => categories.id)
     });
    
     // Relations for products
     export const productsRelations = relations(products, ({ many }) => ({
         productsToCategories: many(productsToCategories),
     }));
    
     // Relations for the junction table
     export const productsToCategoriesRelations = relations(
       productsToCategories,
       ({ one }) => ({
         product: one(products, {
           fields: [productsToCategories.productId],
           references: [products.id],
         }),
         category: one(categories, {
           fields: [productsToCategories.categoryId],
           references: [categories.id],
         }),
       })
     );
    

    Now let’s query it:

     // Get all products in a category
     const electronicsProducts = await db.query.categories.findFirst({
       where: (categories, { eq }) => eq(categories.slug, 'electronics'),
       with: {
         productsToCategories: {
           with: {
             product: true,
           },
         },
       },
     });
    
     // Get all categories for a product
     const productCategories = await db.query.products.findFirst({
       where: (products, { eq }) => eq(products.id, 1),
       with: {
         productsToCategories: {
           with: {
             category: true,
           },
         },
       },
     });
    

    Pro tip: The join table can store additional data like addedAt timestamps or displayOrder for sorting. This flexibility is powerful.

  3. One-to-One: The Rare but Useful Relationship

    One-to-one relationships are less common but crucial when you need them. Think user profiles: one user has exactly one profile, and one profile belongs to exactly one user.

     // schema.ts
     export const userProfiles = pgTable('user_profiles', {
       id: serial('id').primaryKey(),
       userId: integer('user_id').notNull().unique().references(() => users.id),
       bio: text('bio'),
       avatarUrl: text('avatar_url'),
       phoneNumber: text('phone_number'),
       address: text('address'),
     });
    
     // add the profile relationship to our usersRelations
     export const usersRelations = relations(users, ({ one, many }) => ({
       profile: one(userProfiles),
       orders: many(orders),
     }));
    
     export const userProfilesRelations = relations(userProfiles, ({ one }) => ({
       user: one(users, {
         fields: [userProfiles.userId],
         references: [users.id],
       }),
     }));
    

    Query it like this:

     const userWithProfile = await db.query.users.findFirst({
       where: (users, { eq }) => eq(users.id, 1),
       with: {
         profile: true,
       },
     });
    

The Complete E-Commerce Schema

Let’s tie it all together. Here’s a real-world schema for order items and its relationship types:

// schema.ts
export const orderItems = pgTable('order_items', {
  id: serial('id').primaryKey(),
  orderId: integer('order_id').notNull().references(() => orders.id),
  productId: integer('product_id').notNull().references(() => products.id),
  quantity: integer('quantity').notNull(),
  price: integer('price').notNull(),
});

export const orderItemsRelations = relations(orderItems, ({ one }) => ({
  order: one(orders, {
    fields: [orderItems.orderId],
    references: [orders.id],
  }),
  product: one(products, {
    fields: [orderItems.productId],
    references: [products.id],
  }),
}));

// Add orderItems in our existing relationship
export const ordersRelations = relations(orders, ({ one, many }) => ({
  user: one(users, {
    fields: [orders.userId],
    references: [users.id],
  }),
  orderItems: many(orderItems),
}));

Now for the mind-blowing query—get a complete order with user info, all items, and product details:

const completeOrder = await db.query.orders.findFirst({
  where: (orders, { eq }) => eq(orders.id, 1),
  with: {
    user: {
      with: {
        profile: true,
      },
    },
    orderItems: {
      with: {
        product: {
          with: {
            productsToCategories: {
              with: {
                category: true,
              },
            },
          },
        },
      },
    },
  },
});

This single query gives you everything. In raw SQL, this would be a nightmare of joins. It reminds me of my days working Jupyter notebooks. With Drizzle, it’s readable, type-safe, and maintainable.

Performance Tips You Can’t Ignore

  1. Don’t over-fetch. Just because you can nest relations doesn’t mean you should always fetch everything. Be intentional.

  2. Use findMany with limits:

     const recentOrders = await db.query.orders.findMany({
       limit: 10,
       orderBy: (orders, { desc }) => [desc(orders.createdAt)],
       with: {
         user: true,
       },
     });
    
  3. Leverage Drizzle’s prepared statements for repeated queries:

     const getUserWithOrders = db.query.users.findFirst({
       where: (users, { eq }) => eq(users.id, sql.placeholder('userId')),
       with: { orders: true },
     }).prepare('get_user_orders');
    
     // Reuse it
     const user1 = await getUserWithOrders.execute({ userId: 1 });
     const user2 = await getUserWithOrders.execute({ userId: 2 });
    

Common Pitfalls (And How to Avoid Them)

Mistake 1: Forgetting to define both sides of a relation; Always define relations on both tables. If you have users → orders, define it on both users and orders tables.

Mistake 2: Circular dependencies in relations Keep your relation definitions in the same file or be careful with imports to avoid circular dependency issues.

Mistake 3: Not using indexes. Add indexes to foreign keys for better query performance:

export const orders = pgTable('orders', {
  id: serial('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id),
  // ... other fields
}, (table) => ({
  userIdIdx: index('user_id_idx').on(table.userId),
}));

Why This Matters in Real Projects

The performance boost is always noticeable when it comes to Drizzle and also what impresses me is the developer experience. The queries feel natural, the Typescript integration is flawless, and I can still drop to raw SQL when needed.

Database relations aren’t about storing data—they’re about modeling your domain accurately. When you nail your relations, the rest of your application becomes easier to build and maintain.

Your Turn

What's your biggest struggle with database relations? Are you Team Raw SQL, Team ORM, or somewhere in between? Drop a comment below—I'd love to hear about the gnarliest relation problem you've had to solve.

And if you found this helpful, consider sharing it with someone who's still struggling with database relationships. We've all been there.

Resources