On this page

Relations between entities and database migrations

14 min read TextCh. 3 — Data and Persistence

Relations in TypeORM

Database relationships define how tables are connected. TypeORM supports all standard relation types through decorators:

Decorator Relation type Example
@OneToOne One-to-one User → Profile
@OneToMany One-to-many Author → Books
@ManyToOne Many-to-one Book → Author
@ManyToMany Many-to-many Book ↔ Categories

Relations are always defined on both sides of the relationship — the owning side holds the foreign key, and the inverse side declares the reverse navigation.

OneToMany and ManyToOne

The most common relation. An author can write many books; each book has one author.

The @ManyToOne side owns the foreign key (author_id column):

// book.entity.ts — owning side (has the foreign key column)
@ManyToOne(() => Author, (author) => author.books, {
  nullable: false,
  onDelete: 'RESTRICT',
  eager: false,
})
@JoinColumn({ name: 'author_id' })
author: Author;

@Column({ name: 'author_id' })
authorId: number;

The @OneToMany side is the inverse:

// author.entity.ts — inverse side
@OneToMany(() => Book, (book) => book.author)
books: Book[];

The inverse side does not have a column in the database — it is a virtual navigation property resolved via the foreign key in books.

OneToOne

A user has exactly one profile; a profile belongs to exactly one user.

// user.entity.ts
@OneToOne(() => Profile, (profile) => profile.user, { cascade: true })
@JoinColumn()
profile: Profile;

// profile.entity.ts
@OneToOne(() => User, (user) => user.profile)
user: User;

@JoinColumn() on the @OneToOne side creates the foreign key. The side with @JoinColumn owns the relation.

ManyToMany

Books can belong to multiple categories; categories can contain multiple books.

// book.entity.ts
@ManyToMany(() => Category, (category) => category.books, {
  cascade: ['insert'],
})
@JoinTable({
  name: 'book_categories',
  joinColumn: { name: 'book_id' },
  inverseJoinColumn: { name: 'category_id' },
})
categories: Category[];

// category.entity.ts
@ManyToMany(() => Book, (book) => book.categories)
books: Book[];

@JoinTable() must be defined on one side only (the owning side). TypeORM creates a pivot table book_categories automatically.

Relation options

eager and lazy loading

Eager loading — relation is always loaded when the entity is queried:

@ManyToOne(() => Author, (author) => author.books, { eager: true })
author: Author; // always included

Lazy loading — relation is a Promise, loaded on access:

@ManyToOne(() => Author, (author) => author.books, { lazy: true })
author: Promise<Author>; // loaded when awaited

Explicit loading — the recommended approach: load relations only when needed using relations option in find queries or leftJoinAndSelect in query builder.

Cascade options

Cascade controls what operations on the parent automatically apply to the child:

@OneToMany(() => Book, (book) => book.author, {
  cascade: ['insert', 'update'], // insert and update books with author
  // cascade: true means all operations
})
books: Book[];

At the database level, onDelete controls what happens when the parent is deleted:

@ManyToOne(() => Author, (author) => author.books, {
  onDelete: 'RESTRICT',  // Prevent author deletion if books exist
  // onDelete: 'CASCADE'  // Delete books when author is deleted
  // onDelete: 'SET NULL' // Set author_id to NULL when author is deleted
})
author: Author;

Migrations

Migrations are version-controlled database schema changes. Each migration has an up method (apply the change) and a down method (revert it).

Setting up migrations

Create a datasource.ts file at the root of the project for TypeORM CLI access:

// datasource.ts
import { DataSource } from 'typeorm';
import { Book } from './src/books/book.entity';
import { Author } from './src/authors/author.entity';

export default new DataSource({
  type: 'postgres',
  host: process.env['DB_HOST'] ?? 'localhost',
  port: parseInt(process.env['DB_PORT'] ?? '5432', 10),
  username: process.env['DB_USER'] ?? 'postgres',
  password: process.env['DB_PASS'] ?? 'postgres',
  database: process.env['DB_NAME'] ?? 'bookstore',
  entities: [Book, Author],
  migrations: ['src/migrations/*.ts'],
});

Add scripts to package.json:

{
  "scripts": {
    "migration:generate": "npx typeorm migration:generate -d datasource.ts",
    "migration:run": "npx typeorm migration:run -d datasource.ts",
    "migration:revert": "npx typeorm migration:revert -d datasource.ts",
    "migration:create": "npx typeorm migration:create"
  }
}

Generating a migration

After modifying an entity, generate the migration diff:

npm run migration:generate -- src/migrations/AddCoverUrlToBooks

TypeORM compares the entity definitions to the current database schema and generates an up method with the required SQL changes and a down method to revert them.

Running migrations

Apply all pending migrations:

npm run migration:run

TypeORM tracks which migrations have been applied in a migrations table it creates automatically.

Writing manual migrations

When the auto-generator falls short (e.g., for data migrations or complex changes), write migrations manually:

export class SeedDefaultCategories1700000000001 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      INSERT INTO categories (name, slug)
      VALUES
        ('Fiction', 'fiction'),
        ('Non-Fiction', 'non-fiction'),
        ('Science', 'science')
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      DELETE FROM categories WHERE slug IN ('fiction', 'non-fiction', 'science')
    `);
  }
}

Running migrations in production

Configure TypeORM to run migrations automatically on startup:

TypeOrmModule.forRoot({
  // ...
  synchronize: false,      // Never sync in production
  migrationsRun: true,     // Run pending migrations on startup
  migrations: [__dirname + '/migrations/*.js'],
})

This is the recommended production setup: no synchronize, migrations applied automatically, and all changes version-controlled.

The migration workflow

The complete workflow for schema changes in production:

  1. Modify the entity class
  2. Generate migration: npm run migration:generate -- src/migrations/DescribeChange
  3. Review the generated SQL
  4. Commit entity + migration together
  5. CI/CD pipeline runs migration:run during deployment

This ensures every environment (dev, staging, production) goes through the exact same schema changes in sequence.

Generating migrations automatically
TypeORM can diff your entities against the current DB schema and generate migration files: npx typeorm migration:generate -d datasource.ts src/migrations/CreateBooks. Review the generated SQL before running it — the generator is helpful but not infallible.
Cascade delete danger
Using cascade: ['remove'] or onDelete: 'CASCADE' at the database level will delete all related rows automatically. For example, deleting an author with cascade will delete all their books. Always double-check cascading rules against your business requirements before applying them.
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  CreateDateColumn,
} from 'typeorm';
import { Book } from '../books/book.entity';

@Entity('authors')
export class Author {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  firstName: string;

  @Column({ length: 100 })
  lastName: string;

  @Column({ unique: true })
  email: string;

  @OneToMany(() => Book, (book) => book.author, {
    cascade: ['insert', 'update'],
  })
  books: Book[];

  @CreateDateColumn()
  createdAt: Date;
}