On this page
Relations between entities and database migrations
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 includedLazy loading — relation is a Promise, loaded on access:
@ManyToOne(() => Author, (author) => author.books, { lazy: true })
author: Promise<Author>; // loaded when awaitedExplicit 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/AddCoverUrlToBooksTypeORM 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:runTypeORM 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:
- Modify the entity class
- Generate migration:
npm run migration:generate -- src/migrations/DescribeChange - Review the generated SQL
- Commit entity + migration together
- CI/CD pipeline runs
migration:runduring deployment
This ensures every environment (dev, staging, production) goes through the exact same schema changes in sequence.
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;
}
Sign in to track your progress