Skip to content
NestJS ns database 5 min read

TypeORM Relations

Real applications are graphs, not isolated tables: a user has many posts, a post belongs to one author, and posts wear many tags. TypeORM models these connections with relation decorators that map directly to foreign keys and join tables, letting you traverse data as typed object references instead of hand-written joins. Getting relations right — which side owns the foreign key, how rows are loaded, and what cascades on save — is the difference between clean repositories and a layer riddled with N+1 queries.

Relation types at a glance

TypeORM supports the four classic cardinalities. Each is declared with a decorator on both sides of the association, and the relation is resolved by the inverse-side callback that points back at the partner entity.

RelationOwning-side decoratorInverse-side decoratorForeign key lives on
One-to-one@OneToOne + @JoinColumn@OneToOneThe side with @JoinColumn
Many-to-one@ManyToOne@OneToManyThe “many” side (always)
One-to-many@OneToMany@ManyToOneThe “many” side (always)
Many-to-many@ManyToMany + @JoinTable@ManyToManyA separate junction table

One-to-many and many-to-one

This is the most common pairing. A Post has exactly one author, and a User has many posts. The foreign key always sits on the “many” side — here post.authorId — which is why @ManyToOne is the owning side and @OneToMany is purely inverse.

// src/users/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Post } from '../posts/post.entity';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
}
// src/posts/post.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../users/user.entity';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  title: string;

  @ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'author_id' })
  author: User;
}

@JoinColumn lets you name the foreign-key column explicitly; without it TypeORM derives one like authorId. The onDelete: 'CASCADE' option is a database-level rule that removes a user’s posts when the user row is deleted.

One-to-one

Use one-to-one for a record that extends another with optional or sensitive data — a Profile for a User. Exactly one side carries @JoinColumn, and that side owns the foreign key.

import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';

@Entity('profiles')
export class Profile {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'text', nullable: true })
  bio: string | null;

  @OneToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn()
  user: User;
}

Many-to-many

Posts and tags relate many-to-many, which requires a junction table. The owning side declares @JoinTable; TypeORM then creates and maintains the join table automatically.

import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Tag } from './tag.entity';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  title: string;

  @ManyToMany(() => Tag, (tag) => tag.posts, { cascade: true })
  @JoinTable({ name: 'post_tags' })
  tags: Tag[];
}
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { Post } from './post.entity';

@Entity('tags')
export class Tag {
  @PrimaryGeneratedColumn('uuid')
  id: string;

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

  @ManyToMany(() => Post, (post) => post.tags)
  posts: Post[];
}

Cascade options

Cascades control what TypeORM does to related entities when you save or remove a parent. They operate at the ORM level (distinct from the database-level onDelete). Enable them per relation with the cascade option.

ValueEffect
trueAll cascade operations are enabled
['insert']New related entities are inserted with the parent
['update']Changed related entities are updated with the parent
['remove']Related entities are removed with the parent
['soft-remove']Soft-delete propagates to related entities

With cascade: true you can persist a parent and its children in a single save:

const post = postRepo.create({
  title: 'TypeORM relations',
  tags: [tagRepo.create({ name: 'orm' }), tagRepo.create({ name: 'nestjs' })],
});
await postRepo.save(post); // post + both tags + join rows in one call

Avoid cascade: true on relations whose children are shared across many parents (like tags). A stray edit can rewrite rows you didn’t intend to touch. Prefer narrow arrays such as ['insert'] and manage updates explicitly.

Eager vs lazy loading

By default a relation is not loaded — you must ask for it. You can change the default per relation:

  • Eager (eager: true): the relation loads automatically on every find call. Convenient, but it fires extra joins everywhere and can balloon query cost. Eager relations are ignored by QueryBuilder.
  • Lazy (typed as Promise<T>): the relation loads on first access via an await. It adds a query per access, so it is easy to trigger N+1 problems in loops.
@ManyToOne(() => User, (user) => user.posts, { eager: true })
author: User; // always populated by find()

@OneToMany(() => Post, (post) => post.author, { lazy: true })
posts: Promise<Post[]>; // await postsPromise to load

Loading relations explicitly

The most predictable approach is to leave relations non-eager and request them per query with the relations option. It accepts an array of paths or a nested object for deep loading.

// Array form
const posts = await postRepo.find({
  relations: ['author', 'tags'],
});

// Object form supports nesting
const post = await postRepo.findOne({
  where: { id },
  relations: { author: true, tags: true },
});

Output:

query: SELECT "Post"."id", "Post"."title", "author"."id", "author"."name"
  FROM "posts" "Post"
  LEFT JOIN "users" "author" ON "author"."id" = "Post"."author_id"
  LEFT JOIN "post_tags" "Post_tags" ON "Post_tags"."postId" = "Post"."id"
  LEFT JOIN "tags" "tags" ON "tags"."id" = "Post_tags"."tagId"

For finer control — filtering or paginating related rows — reach for QueryBuilder.leftJoinAndSelect instead of the relations option.

Best practices

  • Remember the foreign key always lives on the @ManyToOne side; make that side the owner and use @OneToMany only as the inverse.
  • Prefer explicit per-query relations over eager: true so each endpoint loads exactly the data it needs.
  • Reserve eager for tiny, always-needed associations; never on collections that grow without bound.
  • Use narrow cascade arrays (['insert']) rather than cascade: true, especially for shared entities like tags.
  • Combine ORM cascade with database onDelete: 'CASCADE' deliberately — they solve different layers of the same problem.
  • Watch for N+1 queries when iterating lazy relations; batch with relations or a single leftJoinAndSelect instead.
  • Name join columns and join tables (@JoinColumn({ name }), @JoinTable({ name })) so migrations stay stable.
Last updated June 14, 2026
Was this helpful?