Home Database Sequelize ORM for Node.js: Models, Associations and Real-World Patterns

Sequelize ORM for Node.js: Models, Associations and Real-World Patterns

In Plain English 🔥
Imagine your database is a giant filing cabinet, and every time you want a document you have to speak in a weird filing-clerk language (SQL). Sequelize is like hiring a smart assistant who speaks both your language (JavaScript) and the clerk's language (SQL) fluently. You say 'get me all users who signed up this month' in plain JavaScript, and the assistant translates it, fetches the files, and hands them back as neat JavaScript objects. You never have to touch the filing clerk's weird language at all.
⚡ Quick Answer
Imagine your database is a giant filing cabinet, and every time you want a document you have to speak in a weird filing-clerk language (SQL). Sequelize is like hiring a smart assistant who speaks both your language (JavaScript) and the clerk's language (SQL) fluently. You say 'get me all users who signed up this month' in plain JavaScript, and the assistant translates it, fetches the files, and hands them back as neat JavaScript objects. You never have to touch the filing clerk's weird language at all.

Every production Node.js app eventually hits the same wall: your data lives in a relational database like PostgreSQL or MySQL, but your entire codebase is JavaScript. Writing raw SQL strings inside JavaScript files is painful — they're hard to read, impossible to refactor safely, and one typo away from a runtime crash. As your schema grows, keeping SQL strings in sync with your actual database becomes a full-time job nobody signed up for.

Sequelize solves this by giving your database tables a JavaScript identity. Instead of writing 'SELECT * FROM orders WHERE user_id = 42', you write Order.findAll({ where: { userId: 42 } }). Your tables become classes, your rows become objects, and relationships between tables become method calls. More importantly, Sequelize brings migrations — version-controlled, repeatable schema changes that let your whole team evolve the database safely without ever saying 'just run this SQL script I emailed you'.

By the end of this article you'll know how to define models that map to real database tables, wire up associations like hasMany and belongsTo so Sequelize handles JOINs for you, write migrations that your team can run reliably, and avoid the three most common mistakes that trip up developers moving from raw SQL to an ORM. The code examples use a real e-commerce scenario — Users, Products, and Orders — so everything connects to something you'd actually build.

Setting Up Sequelize and Connecting to PostgreSQL

Before you write a single model, Sequelize needs to know which database it's talking to and how to reach it. The connection lives in a Sequelize instance, and that instance gets shared across your entire app. Getting this setup right saves you from the classic 'why is every query timing out in production?' mystery.

Sequelize supports PostgreSQL, MySQL, MariaDB, SQLite, and MSSQL — but the setup is almost identical for all of them. You swap the dialect, and everything else stays the same. That's the point.

The key decision here is using a connection pool. Databases have a limit on simultaneous connections. Without pooling, each request opens a fresh connection and slams it shut — slow and wasteful. With pooling, Sequelize keeps a warm set of connections ready and recycles them. For a web server handling concurrent requests, this is not optional.

Keep your credentials out of your code. Use environment variables from day one. Even on a personal project. The habit will save you from a very bad day when you accidentally push to a public repo.

database/connection.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142
// database/connection.js
// This file creates ONE shared Sequelize instance for the entire app.
// Import this wherever you need database access — never create a second instance.

const { Sequelize } = require('sequelize');

// Pull credentials from environment variables — NEVER hardcode these
const DB_NAME = process.env.DB_NAME || 'storefront_db';
const DB_USER = process.env.DB_USER || 'postgres';
const DB_PASS = process.env.DB_PASS || 'supersecret';
const DB_HOST = process.env.DB_HOST || 'localhost';

const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
  host: DB_HOST,
  dialect: 'postgres',       // swap to 'mysql' or 'sqlite' without touching anything else

  pool: {
    max: 10,                 // max 10 simultaneous connections — tune for your server
    min: 2,                  // always keep 2 warm connections ready
    acquire: 30000,          // wait up to 30s to get a connection before throwing
    idle: 10000              // release a connection that's been idle for 10s
  },

  logging: process.env.NODE_ENV === 'development'
    ? (sql) => console.log('[SQL]', sql)  // log queries in dev — silence in prod
    : false
});

// Test the connection on startup so you know immediately if credentials are wrong
async function testConnection() {
  try {
    await sequelize.authenticate();
    console.log('Database connection established successfully.');
  } catch (error) {
    console.error('Unable to connect to the database:', error.message);
    process.exit(1); // kill the app — there is no point running without a DB
  }
}

testConnection();

module.exports = sequelize;
▶ Output
Database connection established successfully.
[SQL] SELECT 1+1 AS result (only visible in NODE_ENV=development)
⚠️
Watch Out: Multiple Sequelize InstancesRequiring sequelize/connection.js in 10 files is fine — Node.js caches the module, so you get the same instance every time. But if you ever call new Sequelize(...) in two different files, you'll create two separate connection pools and your models won't share state. Always export a single instance.

Defining Models That Mirror Your Database Tables

A Sequelize model is a JavaScript class that represents a database table. Every property you define on the model maps to a column. Sequelize uses this definition both to validate data before it hits the database and to generate the SQL for you.

The cleanest way to define models in modern Sequelize (v6+) is with sequelize.define() or by extending Model and calling Model.init(). The extend approach is better for larger apps because it gives you a real class you can attach methods to.

Data types matter more than beginners expect. Using DataTypes.STRING where you should use DataTypes.TEXT won't break anything immediately — but STRING maps to VARCHAR(255) which will silently truncate any content longer than 255 characters. No error. Just lost data. Choosing the right type is part of your contract with the database.

Validations live right next to your field definitions. This is the real power: your business rules (a product price can't be negative, an email must look like an email) live in one place and run before any database call is made. You're not checking twice — once in application code and once with a database constraint. Sequelize can do both simultaneously.

models/Product.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// models/Product.js
// The Product model maps to the 'products' table in our database.
// We use the class-extension pattern so we can add instance methods later.

const { Model, DataTypes } = require('sequelize');
const sequelize = require('../database/connection');

class Product extends Model {
  // Instance method — available on any product object returned from a query
  getFormattedPrice() {
    // 'this' refers to the specific product row fetched from the DB
    return `$${(this.priceInCents / 100).toFixed(2)}`;
  }

  isInStock() {
    return this.stockQuantity > 0;
  }
}

Product.init(
  {
    // Sequelize adds 'id' as an auto-incrementing primary key by default,
    // but we define it explicitly here for clarity and UUID support if needed
    id: {
      type: DataTypes.INTEGER,
      autoIncrement: true,
      primaryKey: true
    },

    name: {
      type: DataTypes.STRING(200),  // VARCHAR(200) — not TEXT, names are short
      allowNull: false,             // NOT NULL constraint at the DB level
      validate: {
        notEmpty: { msg: 'Product name cannot be blank' },
        len: { args: [2, 200], msg: 'Name must be between 2 and 200 characters' }
      }
    },

    description: {
      type: DataTypes.TEXT,         // TEXT — descriptions can be long
      allowNull: true               // optional field is fine
    },

    priceInCents: {
      type: DataTypes.INTEGER,      // Store money as integers (cents) — NEVER floats
      allowNull: false,
      validate: {
        min: { args: [0], msg: 'Price cannot be negative' },
        isInt: { msg: 'Price must be a whole number of cents' }
      }
    },

    stockQuantity: {
      type: DataTypes.INTEGER,
      defaultValue: 0,             // new products start with 0 stock unless specified
      validate: {
        min: { args: [0], msg: 'Stock cannot go below zero' }
      }
    },

    isActive: {
      type: DataTypes.BOOLEAN,
      defaultValue: true           // new products are active by default
    }
  },
  {
    sequelize,                     // pass the connection instance
    modelName: 'Product',          // used internally by Sequelize
    tableName: 'products',         // explicit table name — don't let Sequelize guess
    timestamps: true,              // adds createdAt and updatedAt columns automatically
    underscored: true              // maps camelCase JS fields to snake_case DB columns
  }
);

// --- Quick usage demo ---
async function demoProductCreation() {
  // sync({ force: false }) creates the table if it doesn't exist — safe for dev
  await sequelize.sync({ alter: false });

  const laptop = await Product.create({
    name: 'Pro Laptop 15',
    description: 'A laptop built for developers.',
    priceInCents: 149999,          // $1,499.99 — stored as 149999 cents
    stockQuantity: 25
  });

  console.log('Created product:', laptop.name);
  console.log('Formatted price:', laptop.getFormattedPrice());
  console.log('In stock?', laptop.isInStock());
  console.log('Created at:', laptop.createdAt);
}

demoProductCreation();

module.exports = Product;
▶ Output
Database connection established successfully.
Created product: Pro Laptop 15
Formatted price: $1499.99
In stock? true
Created at: 2024-03-15T10:23:44.112Z
⚠️
Pro Tip: Store Money as IntegersNever store currency as a float (99.99). Floating point arithmetic makes 0.1 + 0.2 equal 0.30000000000000004 in JavaScript. Store prices in the smallest unit (cents, pence) as integers, then divide by 100 only when displaying. Your accounting team will thank you.

Associations — Teaching Sequelize How Your Tables Relate

A database without relationships is just a spreadsheet. The real power of a relational database — and of Sequelize — is expressing that a User has many Orders, and each Order belongs to one User, and Sequelize can fetch them together in a single query.

Sequelize has four association types: hasOne, hasMany, belongsTo, and belongsToMany. The critical thing most tutorials miss is that associations always come in pairs. If User hasMany Orders, then Order must also declare belongsTo User. Declare only one side and Sequelize won't build the JOIN methods on the other model — you'll get confusing 'is not a function' errors.

Where you define associations matters. Put them all in one central file (usually your main index.js or a dedicated models/index.js). Defining them inside the model files themselves causes circular require() problems because User.js requires Order.js which requires User.js — Node.js gets confused and hands you an empty object.

Once associations are set up, eager loading with include is how you replace a multi-step JOIN with one elegant query. Fetch a user and all their orders with product details in a single round trip to the database.

models/index.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// models/index.js
// This is the single source of truth for all model associations.
// Import THIS file everywhere — not the individual model files.

const sequelize = require('../database/connection');
const { DataTypes, Model } = require('sequelize');

// --- Define User model ---
class User extends Model {}
User.init(
  {
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
    fullName: { type: DataTypes.STRING(150), allowNull: false },
    email: {
      type: DataTypes.STRING(255),
      allowNull: false,
      unique: true,              // DB-level unique constraint
      validate: { isEmail: { msg: 'Must be a valid email address' } }
    },
    passwordHash: { type: DataTypes.STRING(255), allowNull: false }
  },
  { sequelize, modelName: 'User', tableName: 'users', timestamps: true, underscored: true }
);

// --- Define Order model ---
class Order extends Model {}
Order.init(
  {
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
    // userId is the foreign key — Sequelize can add this automatically via associations,
    // but declaring it explicitly gives you control over the column name and constraints
    userId: {
      type: DataTypes.INTEGER,
      allowNull: false,
      references: { model: 'users', key: 'id' }  // enforces FK at DB level
    },
    status: {
      type: DataTypes.ENUM('pending', 'processing', 'shipped', 'delivered', 'cancelled'),
      defaultValue: 'pending'
    },
    totalInCents: { type: DataTypes.INTEGER, allowNull: false }
  },
  { sequelize, modelName: 'Order', tableName: 'orders', timestamps: true, underscored: true }
);

// --- Define OrderItem (the join table for Orders <-> Products) ---
class OrderItem extends Model {}
OrderItem.init(
  {
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
    quantity: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1 } },
    unitPriceInCents: { type: DataTypes.INTEGER, allowNull: false }  // snapshot of price at purchase time
  },
  { sequelize, modelName: 'OrderItem', tableName: 'order_items', timestamps: false, underscored: true }
);

// Import Product from its own file (no circular dep risk since Product doesn't import models/index)
const Product = require('./Product');

// =====================================================================
// ASSOCIATIONS — defined HERE, not inside individual model files
// Both sides of every relationship must be declared.
// =====================================================================

// A User can place many Orders
User.hasMany(Order, {
  foreignKey: 'userId',
  as: 'orders'           // alias used in 'include' queries
});
// Each Order belongs to exactly one User
Order.belongsTo(User, {
  foreignKey: 'userId',
  as: 'customer'         // alias: order.customer gives you the User object
});

// An Order has many OrderItems (line items)
Order.hasMany(OrderItem, { foreignKey: 'orderId', as: 'lineItems' });
OrderItem.belongsTo(Order, { foreignKey: 'orderId', as: 'order' });

// A Product can appear in many OrderItems
Product.hasMany(OrderItem, { foreignKey: 'productId', as: 'orderItems' });
OrderItem.belongsTo(Product, { foreignKey: 'productId', as: 'product' });

// =====================================================================
// EAGER LOADING DEMO
// Fetch a user + all their orders + each order's line items + product names
// This produces ONE round trip to the DB — not N+1 queries
// =====================================================================
async function getUserOrderHistory(userId) {
  const userWithOrders = await User.findByPk(userId, {
    include: [
      {
        model: Order,
        as: 'orders',
        include: [
          {
            model: OrderItem,
            as: 'lineItems',
            include: [
              { model: Product, as: 'product', attributes: ['name', 'priceInCents'] }
            ]
          }
        ]
      }
    ],
    // Only return safe fields — never return passwordHash to the client
    attributes: { exclude: ['passwordHash'] }
  });

  if (!userWithOrders) {
    throw new Error(`User ${userId} not found`);
  }

  return userWithOrders;
}

// Sync all tables in dependency order (users before orders, etc.)
async function syncAllModels() {
  await sequelize.sync({ alter: true }); // alter: true updates columns without dropping data
  console.log('All tables synced.');
}

module.exports = { sequelize, User, Order, OrderItem, Product, getUserOrderHistory, syncAllModels };
▶ Output
Database connection established successfully.
All tables synced.
// getUserOrderHistory(1) returns:
{
id: 1,
fullName: 'Sarah Mitchell',
email: 'sarah@example.com',
orders: [
{
id: 7,
status: 'delivered',
totalInCents: 149999,
lineItems: [
{
quantity: 1,
unitPriceInCents: 149999,
product: { name: 'Pro Laptop 15', priceInCents: 149999 }
}
]
}
]
}
⚠️
Watch Out: The N+1 Query ProblemWithout 'include', fetching 100 orders and then calling order.getLineItems() in a loop fires 101 separate queries — one for orders, then one per order. This is the N+1 problem, and it tanks performance at scale. Always use 'include' (eager loading) when you know you'll need related data. Use Sequelize's logging in dev to count your queries — you'll often be surprised.

Migrations — Version-Controlling Your Database Schema

Migrations are the unsung hero of professional database management. A migration is a JavaScript file with an up() function (apply a change) and a down() function (reverse it). Every schema change — adding a column, creating a table, adding an index — lives in a migration file that gets committed to Git. When a team member pulls your code, they run 'npx sequelize-cli db:migrate' and their database matches yours exactly. No more 'it works on my machine'.

Sequelize CLI manages migrations. It tracks which ones have run in a SequelizeMeta table in your database, so running the command twice is safe — it skips already-applied migrations.

The discipline is: never use sequelize.sync({ force: true }) in production. That command drops and recreates every table. You will lose all your data. Migrations are how production schemas change.

Notice the down() function in every migration. This is your escape hatch. If a migration causes a production bug, you run db:migrate:undo and the database rolls back to its previous state. A migration without a proper down() is a one-way door.

migrations/20240315102344-add-discount-code-to-orders.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// migrations/20240315102344-add-discount-code-to-orders.js
//
// Scenario: The marketing team wants to track discount codes on orders.
// We need to add a nullable 'discount_code' column to the orders table.
//
// Run this with: npx sequelize-cli db:migrate
// Undo this with: npx sequelize-cli db:migrate:undo

'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  async up(queryInterface, Sequelize) {
    // queryInterface is Sequelize's low-level tool for schema changes
    // It does NOT go through your model — it speaks directly to the DB

    await queryInterface.addColumn(
      'orders',              // the exact table name in the database
      'discount_code',       // snake_case to match our 'underscored: true' config
      {
        type: Sequelize.STRING(50),
        allowNull: true,     // nullable — most orders won't have a discount
        defaultValue: null,
        after: 'status'      // MySQL-only: places the column after 'status' column
      }
    );

    // Add an index on discount_code so marketing can query by code efficiently
    await queryInterface.addIndex('orders', ['discount_code'], {
      name: 'orders_discount_code_idx',
      where: {
        discount_code: { [Sequelize.Op.ne]: null }  // partial index — only non-null rows
      }
    });

    console.log('Migration up: discount_code column added to orders.');
  },

  async down(queryInterface, Sequelize) {
    // The down() MUST be the exact reverse of up()
    // Always remove the index before removing the column

    await queryInterface.removeIndex('orders', 'orders_discount_code_idx');
    await queryInterface.removeColumn('orders', 'discount_code');

    console.log('Migration down: discount_code column removed from orders.');
  }
};

// =======================================================
// HOW TO GENERATE A NEW MIGRATION FILE:
// npx sequelize-cli migration:generate --name add-discount-code-to-orders
//
// HOW TO CHECK MIGRATION STATUS:
// npx sequelize-cli db:migrate:status
//
// Output:
// up 20240310091200-create-users.js
// up 20240311143000-create-products.js
// up 20240312090000-create-orders.js
// up 20240315102344-add-discount-code-to-orders.js
// =======================================================
▶ Output
== 20240315102344-add-discount-code-to-orders: migrating =======
Migration up: discount_code column added to orders.
== 20240315102344-add-discount-code-to-orders: migrated (0.312s)

npx sequelize-cli db:migrate:status
up 20240310091200-create-users.js
up 20240311143000-create-products.js
up 20240312090000-create-orders.js
up 20240315102344-add-discount-code-to-orders.js
🔥
Interview Gold: sync() vs MigrationsInterviewers love asking 'how do you handle schema changes in production?' The answer they want: migrations via Sequelize CLI. sequelize.sync() is a dev convenience — it's never appropriate for a shared or production database because it has no rollback mechanism and no history. Knowing this difference signals you've worked on a real team.
AspectRaw SQL in Node.jsSequelize ORM
SyntaxTemplate literal strings — error-prone and hard to refactorJavaScript method calls — autocomplete, linting and refactor support
SQL Injection riskHigh — easy to forget parameterizationLow — parameterized queries are the default, not an opt-in
Schema changesManual ALTER TABLE scripts shared by email or SlackMigration files committed to Git — reproducible by the whole team
RelationshipsManual JOIN strings written per queryDefined once as associations, used via include in every query
ValidationMust be coded separately in application layerDefined alongside field definitions — runs before the DB call
Learning curveLow initial cost — just SQLMedium — need to learn model/association/migration concepts
Query flexibilityComplete — write any SQL you can imagineHigh for CRUD — drops to raw queries for very complex analytics
Multi-DB supportNone — SQL dialects differ between DBsSwitch dialect in config — same model code works on PG, MySQL, SQLite
Performance overheadMinimal — direct driver callsSlight — object hydration and query building add ~1-5ms per query

🎯 Key Takeaways

  • Store one shared Sequelize instance — Node.js module caching ensures require('../database/connection') returns the same pool everywhere, not a new connection each time.
  • Store money as integers (cents) in the database — floating point arithmetic loses precision silently, and there is no way to recover that data once it's wrong.
  • Both sides of every association must be declared — User.hasMany(Order) without Order.belongsTo(User) means the JOIN methods won't exist on Order, and you'll get cryptic 'is not a function' errors.
  • Migrations are version control for your database — every schema change becomes a committed, reversible file your whole team can reproduce with one CLI command, making 'it works on my machine' impossible.

⚠ Common Mistakes to Avoid

  • Mistake 1: Calling sequelize.sync({ force: true }) in production — This silently drops every table and recreates it from scratch, deleting all your data. There is no warning and no undo. Fix: Never use force: true outside a local dev environment. Use alter: true in dev to safely update schema, and use proper migrations (sequelize-cli db:migrate) in any shared or production environment.
  • Mistake 2: Defining associations inside individual model files — When User.js imports Order.js for associations and Order.js imports User.js, Node.js circular require() resolves one of them as an empty object. Your association silently fails and you get 'User.hasMany is not a function' or associations that produce no JOIN. Fix: Define ALL associations in a central models/index.js file that imports all models first, then wires them up. This is the standard pattern for a reason.
  • Mistake 3: Using lazy loading in a loop (the N+1 problem) — Fetching 50 orders then calling order.getLineItems() in a forEach loop fires 51 database queries. With 500 orders it's 501 queries. The page goes from 50ms to 5 seconds. Fix: Use include (eager loading) in the original findAll/findByPk call to JOIN the related data in one query. Enable SQL logging during development (logging: console.log in your Sequelize config) so you can actually see and count the queries you're generating.

Interview Questions on This Topic

  • QWhat's the difference between eager loading and lazy loading in Sequelize, and when would you choose one over the other?
  • QWhy should you use migrations instead of sequelize.sync() to manage your database schema in a production application?
  • QIf you have a User hasMany Orders association and you query User.findAll() without any include, then loop over results calling user.getOrders() for each user — what problem have you introduced, and how would you fix it?

Frequently Asked Questions

Is Sequelize still worth using or should I use Prisma instead?

Both are solid choices in 2024, but they solve the problem differently. Sequelize gives you a classic Active Record-style ORM that feels closer to the database — great if your team knows SQL and wants fine-grained control. Prisma gives you a type-safe query builder generated from a schema file — better developer experience in TypeScript projects. If you're on a JavaScript project or migrating an existing codebase, Sequelize is mature, battle-tested, and has a massive ecosystem. If you're starting a new TypeScript project, Prisma's autocomplete and type inference are hard to beat.

What's the difference between sequelize.sync() and running migrations?

sequelize.sync() inspects your current model definitions and creates or modifies tables to match — it's instant but has no history, no rollback, and with force: true it deletes all data. Migrations are individual versioned files that describe one specific change (add a column, create an index). They're committed to Git, run in order, and can be undone with db:migrate:undo. For any database that more than one person touches, always use migrations.

Why do I have to define associations on both models — why isn't one side enough?

Each association call adds methods to one specific model class. User.hasMany(Order) adds user.getOrders() and user.createOrder() to User — but it does nothing to Order. Order.belongsTo(User) adds order.getCustomer() and order.setCustomer() to Order. Sequelize also only builds the foreign key constraint correctly when both sides are declared. Think of it as each model needing its own directions to find the other — one set of directions only gets you halfway.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousJPA — Java Persistence APINext →Prisma ORM Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged