Skip to content
Spring Boot sb data-jpa 4 min read

@OneToMany & @ManyToOne

A one-to-many relationship is the most common association in JPA: one parent row owns many child rows, such as one Author with many Book rows. In a bidirectional mapping the @ManyToOne side is the owning side that holds the foreign key, and the @OneToMany(mappedBy = "...") side is the inverse side. Getting the ownership right is what keeps your schema clean and your saves correct. For a refresher on the basics, see Entity Mapping.

Owning vs inverse side

JPA needs exactly one side to manage the foreign key. The side that physically stores the FK column is the owning side — here, Book, because each book row carries an author_id. Hibernate only reads the owning side when deciding what SQL to run, so the FK lives there with @JoinColumn.

The @OneToMany collection is the inverse side. By setting mappedBy = "author" you tell Hibernate “this collection is already mapped by the author field on Book. Without mappedBy, Hibernate assumes the two sides are unrelated and creates a redundant join table to link them.

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(
        mappedBy = "author",          // inverse side: FK lives on Book.author
        cascade = CascadeType.ALL,    // persist/remove children with the parent
        orphanRemoval = true          // delete a Book when removed from this list
    )
    private List<Book> books = new ArrayList<>();

    // helper methods keep BOTH sides in sync
    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);         // sets the FK on the owning side
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);         // with orphanRemoval, triggers DELETE
    }

    protected Author() { }
    public Author(String name) { this.name = name; }
    // getters and setters
}
import jakarta.persistence.*;

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)   // owning side
    @JoinColumn(name = "author_id")      // the actual FK column
    private Author author;

    protected Book() { }
    public Book(String title) { this.title = title; }
    // getters and setters
}

Note: Default @ManyToOne fetch is EAGER, which silently triggers extra queries. Almost always set fetch = FetchType.LAZY — see Fetch Types.

Why helper methods matter

In a bidirectional relationship the object graph and the database can drift apart. If you only add a Book to author.getBooks() but never call book.setAuthor(author), the owning side stays null and Hibernate writes a NULL foreign key. The addBook/removeBook helpers update both references in one place so this can never happen.

Author author = new Author("Ursula K. Le Guin");
author.addBook(new Book("A Wizard of Earthsea"));
author.addBook(new Book("The Left Hand of Darkness"));

authorRepository.save(author);   // CascadeType.ALL inserts both books too

Generated SQL

With CascadeType.ALL, saving the parent cascades to the children. The FK column appears on the child table only — no join table.

create table author (
    id     bigint generated by default as identity,
    name   varchar(255),
    primary key (id)
);

create table book (
    id        bigint generated by default as identity,
    title     varchar(255),
    author_id bigint,
    primary key (id),
    constraint fk_book_author
        foreign key (author_id) references author (id)
);

Saving the author above produces:

insert into author (name) values ('Ursula K. Le Guin');
insert into book (title, author_id) values ('A Wizard of Earthsea', 1);
insert into book (title, author_id) values ('The Left Hand of Darkness', 1);

Notice the author_id is set on the INSERT into book because the owning side (Book.author) was populated by the helper method.

Cascade and orphanRemoval

Cascade propagates entity-manager operations from parent to children. orphanRemoval is different: it deletes a child as soon as it is disassociated from the parent collection, even if you never call delete().

FeatureWhat it doesTypical use
cascade = PERSISTSaving parent saves new childrenInsert parent + children together
cascade = ALLAll operations (persist, merge, remove…) cascadeParent fully owns children’s lifecycle
orphanRemoval = trueRemoving child from collection issues DELETEChildren cannot exist without parent
(none)Children managed independentlyShared / long-lived child entities
Author author = authorRepository.findById(1L).orElseThrow();
Book first = author.getBooks().get(0);
author.removeBook(first);   // orphanRemoval -> DELETE from book where id = ?
authorRepository.save(author);
delete from book where id = 1;

Tip: Use orphanRemoval = true only for true parent-child ownership (an OrderItem has no meaning without its Order). For shared entities it can delete rows you did not intend to.

Owning vs inverse side at a glance

AspectOwning side (@ManyToOne)Inverse side (@OneToMany)
Holds the FK columnYes (@JoinColumn)No
Declares mappedByNoYes (points to owning field)
Drives the SQLYes — changes here are persistedNo — changes alone are ignored
Default fetchEAGER (override to LAZY)LAZY
Lives onThe “many” entity (Book)The “one” entity (Author)

Common pitfalls

  • Forgetting mappedBy creates an unwanted join table and double-writes the relationship. Always set it on the @OneToMany side.
  • Updating only the inverse side leaves the FK NULL. Use helper methods.
  • LazyInitializationException when iterating author.getBooks() outside a transaction — fetch the collection inside a @Transactional method or with a JOIN FETCH query. See Transactions.
  • Loading children in a loop is the classic N+1 problem; batch-fetch or join instead.
  • EAGER collections load every child on every query — keep collections LAZY.
Last updated June 13, 2026
Was this helpful?