Sequelize ORM for Node.js: Models, Associations and Real-World Patterns
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 // 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;
[SQL] SELECT 1+1 AS result (only visible in NODE_ENV=development)
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 // 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;
Created product: Pro Laptop 15
Formatted price: $1499.99
In stock? true
Created at: 2024-03-15T10:23:44.112Z
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 // 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 };
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 }
}
]
}
]
}
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 // // 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 // =======================================================
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
| Aspect | Raw SQL in Node.js | Sequelize ORM |
|---|---|---|
| Syntax | Template literal strings — error-prone and hard to refactor | JavaScript method calls — autocomplete, linting and refactor support |
| SQL Injection risk | High — easy to forget parameterization | Low — parameterized queries are the default, not an opt-in |
| Schema changes | Manual ALTER TABLE scripts shared by email or Slack | Migration files committed to Git — reproducible by the whole team |
| Relationships | Manual JOIN strings written per query | Defined once as associations, used via include in every query |
| Validation | Must be coded separately in application layer | Defined alongside field definitions — runs before the DB call |
| Learning curve | Low initial cost — just SQL | Medium — need to learn model/association/migration concepts |
| Query flexibility | Complete — write any SQL you can imagine | High for CRUD — drops to raw queries for very complex analytics |
| Multi-DB support | None — SQL dialects differ between DBs | Switch dialect in config — same model code works on PG, MySQL, SQLite |
| Performance overhead | Minimal — direct driver calls | Slight — 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.
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.