Spring Boot

ManyToOne

@ManyToOne maps the relationship where many child rows point at one parent row through a foreign key column: many comments belong to one post, many orders to one customer. It sits on the child entity, the one whose table holds the foreign key, and that makes it the owning side. It is also the mapping that most often needs no companion: a plain @ManyToOne on the child is frequently all you need, and adding the @OneToMany collection on the parent is optional. The things to get right are the fetch type (the default is EAGER and you almost never want it), how you save and reassign references, what happens when a parent is deleted, how you read parent data into DTOs without triggering N+1 queries, and how to prove with a test that you did not introduce one.

Mapping the Relationship

Put @ManyToOne on the field that references the parent and describe the foreign key with @JoinColumn. Three settings are worth setting deliberately every time. fetch = LAZY, because the JPA default for @ManyToOne is EAGER, which is rarely what you want (the fetching section below explains why). optional = false together with nullable = false when the relationship is mandatory: the database enforces it with NOT NULL, and Hibernate can use a cheaper inner join and skip null checks. And a named foreign key, so constraint violation messages say fk_comments_post instead of an auto-generated hash. Leave the relationship optional (no optional = false, no nullable = false) when the parent may be absent, as in the comment-reply example, which is also a self-reference: a comment pointing at another comment. A null foreign key means a top-level comment. Notice that the parent does not need to know about its children. This unidirectional mapping is complete and is often the best one: you can still load every comment of a post with a repository query on post id, and you avoid maintaining both sides of a collection. Add the collection on the parent only when you genuinely navigate from parent to children, as shown in the next section. Finally, index the foreign key column. MySQL's InnoDB creates that index for you; PostgreSQL does not, and every join or delete on the parent becomes a scan without it.
Java
@Entity
@Table(name = "comments")
public class Comment {

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

    @Column(nullable = false, length = 2000)
    private String body;

    // Mandatory parent: owning side, comments.post_id.
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "post_id", nullable = false,
                foreignKey = @ForeignKey(name = "fk_comments_post"))
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false,
                foreignKey = @ForeignKey(name = "fk_comments_author"))
    private User author;

    // Optional self-reference: null means a top-level comment.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_comment_id",
                foreignKey = @ForeignKey(name = "fk_comments_parent"))
    private Comment parent;

    protected Comment() { }   // required by JPA

    public Comment(String body, Post post, User author) {
        this.body = body;
        this.post = post;
        this.author = author;
    }

    public void moveTo(Post newPost) { this.post = newPost; }

    // Package-private: only the parent's helper methods (next section)
    // should change the link when the collection side is mapped.
    void setPost(Post post) { this.post = post; }

    public Long getId() { return id; }
    public String getBody() { return body; }
    public Post getPost() { return post; }
    public User getAuthor() { return author; }
}

// The parent can stay completely unaware of its comments:
@Entity
@Table(name = "posts")
public class Post {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(nullable = false)
    private String title;

    protected Post() { }
    public Post(String title) { this.title = title; }
    public Long getId() { return id; }
    public String getTitle() { return title; }
}

// Migration (PostgreSQL does not index foreign keys automatically):
//   CREATE INDEX idx_comments_post_id ON comments (post_id);

Adding the Inverse Side Only When You Need It

If code really does walk from a post to its comments (rendering a post with its comment thread, or cascading deletes from the parent), add a @OneToMany on the parent with mappedBy pointing at the child's field. The child keeps owning the foreign key; the collection is only a view of it. Keep both sides consistent through a method on the parent, and give the child a package-private setter so only the parent can change the link. Do not map a collection that can grow without bound. A post with 100,000 comments should not carry a comments list; query them through the repository with paging instead. The details of the collection side (cascade, orphanRemoval, fetching) are covered in the OneToMany entry, so this section only shows the pairing.
Java
@Entity
@Table(name = "posts")
public class Post {

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

    @Column(nullable = false)
    private String title;

    // Inverse side: "post" is the field on Comment that owns the foreign key.
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);          // keep both sides in step
    }

    public void removeComment(Comment comment) {
        comments.remove(comment);
        // orphanRemoval deletes the comment. Do not call comment.setPost(null):
        // the relationship is mandatory (nullable = false), so a null
        // post would break the invariant if the comment is ever reused.
    }

    protected Post() { }
    public Post(String title) { this.title = title; }
    public Long getId() { return id; }
    public String getTitle() { return title; }
    public List<Comment> getComments() { return Collections.unmodifiableList(comments); }
}

// Cascade belongs on the collection side, which owns the child's lifecycle.
// Never put CascadeType.ALL or REMOVE on a @ManyToOne: deleting a comment would then delete the post.

Fetching: Why LAZY Matters and How to Load Parents on Purpose

The JPA default for @ManyToOne is EAGER, which sounds harmless and is not. When you load a single entity with findById, Hibernate joins the parent into the same query. But when you load comments through a JPQL or derived query, Hibernate cannot add the join, so it runs the query and then one extra SELECT per distinct parent, the classic N+1. Every association on the entity behaves this way, so a few eager relationships turn one query into dozens. LAZY avoids all of it: the field holds a lightweight proxy that only queries when you read something other than its id. LAZY then makes you responsible for loading what you need, deliberately, in the query that needs it. Three tools do that. JOIN FETCH in a query fetches the parent in the same SELECT. @EntityGraph does the same declaratively on repository methods. And a global default_batch_fetch_size makes any remaining lazy loads happen in batches rather than one by one, which is a good safety net. A note on derived queries. findByPostId filters on the foreign key column directly and needs no join at all, and the result's post fields stay unloaded proxies. Filtering on another property of the parent, like findByPostTitle, adds a join to the WHERE clause but still does not fetch the parent into the result. Fetching is always a separate, explicit decision.
Java
public interface CommentRepository extends JpaRepository<Comment, Long> {

    // No join: post_id is a column on comments. Each comment's post stays an uninitialised proxy.
    List<Comment> findByPostId(Long postId);
    long countByPostId(Long postId);
    // (If the entity also had a field called postId, write findByPost_Id to be explicit.)

    // Joins posts for the filter only; the returned comments still hold post proxies.
    List<Comment> findByPostTitle(String title);

    // Fetch the parents in the same SELECT, for a single comment...
    @Query("select c from Comment c join fetch c.post join fetch c.author where c.id = :id")
    Optional<Comment> findByIdWithParents(@Param("id") Long id);

    // ...or declaratively, for a list.
    @EntityGraph(attributePaths = {"post", "author"})
    List<Comment> findByPostIdOrderByIdAsc(Long postId);

    // For an OPTIONAL relationship use LEFT JOIN FETCH, or rows with a null parent disappear:
    @Query("select c from Comment c left join fetch c.parent where c.post.id = :postId")
    List<Comment> findWithOptionalParent(@Param("postId") Long postId);
}

// Safety net in application.yml: lazy loads that slip through run in batches, not one per row.
//   spring:
//     jpa:
//       properties:
//         hibernate:
//           default_batch_fetch_size: 25
//
// To see the problem, log the SQL (logging.level.org.hibernate.SQL=DEBUG) and count the statements
// for one request. If the number grows with the number of rows returned, you have an N+1.

Saving and Reassigning References

To create a child you do not need the parent's data, only its id for the foreign key. getReferenceById returns a proxy without running a SELECT, so the insert is a single statement. Use findById when you need to read the parent anyway. There is a trade-off to know. With a proxy, a bad id is not detected when you call getReferenceById: Hibernate only finds out at flush, when the INSERT fails with a foreign key violation (a DataIntegrityViolationException), or when the proxy is first used. If you want a clean 404 for a missing parent, check with existsById first; it is one cheap query and gives you a proper error. Be careful with what you do after saving. Building a response from the saved entity touches the proxies and triggers the SELECTs you just avoided, so return the new id, or map a DTO from data you already hold. Moving a child to another parent is just reassigning the field on a managed entity; dirty checking issues the UPDATE on commit. If you also have the collection mapped and both parents' lists are loaded in the same session, update both lists as well, otherwise they are stale until reloaded. Avoid cascade on @ManyToOne: the parent is normally an independent entity that exists before the child.
Java
@Service
@RequiredArgsConstructor
public class CommentService {

    private final CommentRepository commentRepository;
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    @Transactional
    public Long create(Long postId, CreateCommentRequest request, Long authorId) {
        if (!postRepository.existsById(postId)) {
            throw new PostNotFoundException(postId);          // clean 404, one cheap query
        }
        Post post = postRepository.getReferenceById(postId);   // proxy: no SELECT
        User author = userRepository.getReferenceById(authorId);   // from the security context

        Comment saved = commentRepository.save(new Comment(request.body(), post, author));
        return saved.getId();   // INSERT INTO comments (body, post_id, author_id, ...) VALUES (?, ?, ?, ...)
    }

    @Transactional
    public void move(Long commentId, Long newPostId) {
        Comment comment = commentRepository.findById(commentId).orElseThrow();
        comment.moveTo(postRepository.getReferenceById(newPostId));
        // No save() call needed: dirty checking issues  UPDATE comments SET post_id = ? WHERE id = ?
    }
}

// getReferenceById(id): proxy, no query; a missing row shows up later as a foreign key
//                       violation at flush, or an EntityNotFoundException when the proxy is used.
// findById(id):         runs the SELECT now; use it when you need to read the parent's data.

Deleting Parents and Referential Integrity

The foreign key is a promise from the database: you cannot delete a post while comments still reference it. Delete it anyway and you get a constraint violation, which is correct behaviour, and you decide in advance what should happen instead. There are three reasonable answers. Restrict: leave the constraint as it is and make callers delete or move the children first. This is the safest default for data that matters. Cascade in the application: map the collection on the parent with cascade and orphanRemoval, which works but loads and deletes children one at a time. Cascade in the database: declare ON DELETE CASCADE on the foreign key so the database removes the children in a single operation. If you map the collection and choose this option, add Hibernate's @OnDelete(action = OnDeleteAction.CASCADE) to the @OneToMany(mappedBy) field, so that Hibernate deletes only the parent and leaves the children to the database instead of issuing one DELETE per child. For a large number of children, or a self-reference such as the reply tree, the database-level rule or a bulk delete is the one that performs. A bulk JPQL delete runs as one statement and skips entity loading, callbacks and cascades. It also bypasses the persistence context, so clear it afterwards or entities already loaded will be stale. For optional relationships, ON DELETE SET NULL is often the right choice: deleting a parent comment turns its replies into top-level comments instead of deleting them.
Java
public interface CommentRepository extends JpaRepository<Comment, Long> {

    // One DELETE statement, no entity loading. clearAutomatically empties the stale persistence context.
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("delete from Comment c where c.post.id = :postId")
    int deleteAllForPost(@Param("postId") Long postId);
}

// When the database does the cascading, tell Hibernate on the collection side:
//   @OneToMany(mappedBy = "post")
//   @OnDelete(action = OnDeleteAction.CASCADE)      // org.hibernate.annotations
//   private List<Comment> comments = new ArrayList<>();
// Deleting the Post now issues one DELETE for the post; the database removes its comments.

// Pick the rule in the migration, where the foreign key is defined:
//
//   -- Children must be removed first (restrict, the default):
//   ALTER TABLE comments ADD CONSTRAINT fk_comments_post
//       FOREIGN KEY (post_id) REFERENCES posts (id);
//
//   -- Database removes the comments with their post:
//   ALTER TABLE comments ADD CONSTRAINT fk_comments_post
//       FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE;
//
//   -- Optional reference: deleting a parent comment leaves its replies as top-level comments:
//   ALTER TABLE comments ADD CONSTRAINT fk_comments_parent
//       FOREIGN KEY (parent_comment_id) REFERENCES comments (id) ON DELETE SET NULL;

Reading Parent Data into DTOs and Projections

Do the mapping inside the transaction, and make sure the data you map was loaded on purpose. A lazy association can be read only while the session is open, and reading it outside throws LazyInitializationException. Returning an entity from a controller is worse: Jackson walks every association, loading lazy ones one by one and, with a bidirectional mapping, recursing between parent and child. Return DTOs. Know what actually triggers a query. On a lazy proxy, getId() can usually be answered from the foreign key without touching the database, but any other getter, such as getTitle(), initialises the proxy and runs a SELECT. So the DTO below reads postId for free, and only needs the author fetched, which the entity graph in the service takes care of. When a screen needs a few columns from the child and its parents, skip the entities altogether and select exactly those columns into a projection. One thing to avoid: an interface projection with a flat getter like getPostTitle() does not make Spring Data navigate to post.title; that only works with a nested projection or an explicit query. Writing the query yourself with aliases, as below, is clear and does a single join-based SELECT with no entities and no lazy loading at all.
Java
public record CommentResponse(
        Long id,
        String body,
        Long postId,
        String authorName) {

    // Call inside a transaction, and with the author fetched (see the entity graph below).
    public static CommentResponse from(Comment comment) {
        return new CommentResponse(
            comment.getId(),
            comment.getBody(),
            comment.getPost().getId(),        // a proxy can usually answer getId() without a query
            comment.getAuthor().getName());   // initialises the author: free if it was fetched
    }
}

@Service
@RequiredArgsConstructor
public class CommentQueryService {

    private final CommentRepository commentRepository;

    @Transactional(readOnly = true)
    public List<CommentResponse> findByPostId(Long postId) {
        // findByPostIdOrderByIdAsc has @EntityGraph(attributePaths = {"post", "author"}):
        // one SELECT with joins, no N+1.
        return commentRepository.findByPostIdOrderByIdAsc(postId).stream()
            .map(CommentResponse::from)
            .toList();
    }
}

// Projection: select only the columns the screen needs. Aliases must match the getter names.
public interface CommentSummary {
    Long getId();
    String getBody();
    Long getPostId();
    String getPostTitle();
    String getAuthorName();
}

public interface CommentRepository extends JpaRepository<Comment, Long> {

    @Query("""
            select c.id as id, c.body as body,
                   p.id as postId, p.title as postTitle,
                   a.name as authorName
            from Comment c
            join c.post p
            join c.author a
            where p.id = :postId
            order by c.id
            """)
    List<CommentSummary> findSummariesByPostId(@Param("postId") Long postId);
}

Joining on a Column Other Than the Primary Key

By default the foreign key points at the parent's primary key. When it must point at another unique column, for example a customer's business code, set referencedColumnName on @JoinColumn. The referenced column must be unique in the database; otherwise the relationship is ambiguous and Hibernate may load the wrong row. With a non-primary-key target, a lazy proxy cannot be created from the foreign key value alone in some cases, because the foreign key is not the entity's id, so reading the association can cost a query even if you only want the key. For this reason, prefer joining on the primary key unless an external system genuinely requires the business key, and make sure the column is unique and indexed.
Java
@Entity
@Table(name = "customers")
public class Customer {

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

    @Column(name = "customer_code", nullable = false, unique = true, length = 20)
    private String code;                      // business key, unique

    // ...
}

@Entity
@Table(name = "orders")
public class Order {

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

    // orders.customer_code references customers.customer_code, not customers.id
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_code", referencedColumnName = "customer_code",
                nullable = false,
                foreignKey = @ForeignKey(name = "fk_orders_customer_code"))
    private Customer customer;
}

// The referenced column needs a unique constraint in the database,
// and an index on orders.customer_code for joins:
//   ALTER TABLE customers ADD CONSTRAINT uq_customers_code UNIQUE (customer_code);
//   CREATE INDEX idx_orders_customer_code ON orders (customer_code);

Proving There Is No N+1: Testing Query Counts

N+1 problems are invisible in code review and in small test data, and they appear in production when the table has thousands of rows. Catch them with a test that counts the SQL statements Hibernate runs. Enable Hibernate statistics, clear the persistence context after arranging data (otherwise everything is served from the first-level cache and no SQL runs), run the operation under test, and assert the statement count. Choose a count that does not grow with the number of rows: the test inserts several comments and several posts, and the assertion says the query count stays at one. Run the test against the same database engine you use in production (for example with Testcontainers) when query behaviour matters, because H2 and PostgreSQL can differ.
Java
// application-test.yml (or @DataJpaTest properties):
//   spring.jpa.properties.hibernate.generate_statistics: true

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)  // use your real DB / Testcontainers
class CommentQueryCountTest {

    @Autowired private TestEntityManager em;
    @Autowired private CommentRepository commentRepository;

    @Test
    void findByPostId_loadsAuthorsWithoutNPlusOne() {
        Post post = em.persist(new Post("Hello"));
        for (int i = 0; i < 5; i++) {
            User author = em.persist(new User("user" + i));
            em.persist(new Comment("comment " + i, post, author));
        }
        em.flush();
        em.clear();                               // empty the first-level cache

        Statistics stats = em.getEntityManager().getEntityManagerFactory()
            .unwrap(SessionFactory.class).getStatistics();
        stats.clear();

        List<Comment> comments = commentRepository.findByPostIdOrderByIdAsc(post.getId());
        comments.forEach(c -> c.getAuthor().getName());   // touch the lazy parents

        // One SELECT with joins (entity graph). Without the graph this would be 1 + 5.
        assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
        assertThat(comments).hasSize(5);
    }
}

Common @ManyToOne Mistakes

Most @ManyToOne problems come from a short list of habits. Leaving the default EAGER fetch type causes hidden queries on every load. Returning entities from controllers triggers lazy loading while serialising and can recurse through a bidirectional mapping. Putting cascade REMOVE or ALL on the @ManyToOne side can delete the parent when a child is deleted. Mapping a list of children that can grow without limit loads everything into memory. Forgetting to index the foreign key column on PostgreSQL makes joins and parent deletes slow. Using JOIN FETCH on an optional relationship drops the rows whose parent is null, which looks like missing data; use LEFT JOIN FETCH there. And calling findById just to set a foreign key runs a SELECT you do not need.
Java
// ── 1. EAGER by default ───────────────────────────────────────────────
// ❌ @ManyToOne                                  (JPA default is EAGER)
// ✅ @ManyToOne(fetch = FetchType.LAZY)

// ── 2. Entity returned from a controller ──────────────────────────────
// ❌ @GetMapping("/comments/{id}") Comment get(...)   // lazy loads + recursion
// ✅ @GetMapping("/comments/{id}") CommentResponse get(...)

// ── 3. Cascade on the child side ──────────────────────────────────────
// ❌ @ManyToOne(cascade = CascadeType.ALL)        // deleting a comment can delete the post
// ✅ @ManyToOne(fetch = FetchType.LAZY)           // cascade only on the collection side

// ── 4. Unbounded child collection on the parent ───────────────────────
// ❌ private List<Comment> comments;              // 100,000 comments in memory
// ✅ Page<Comment> findByPostId(Long postId, Pageable pageable);

// ── 5. JOIN FETCH on an optional relationship ─────────────────────────
// ❌ select c from Comment c join fetch c.parent        // top-level comments vanish
// ✅ select c from Comment c left join fetch c.parent

// ── 6. SELECT just to set a foreign key ───────────────────────────────
// ❌ Post post = postRepository.findById(postId).orElseThrow();
// ✅ Post post = postRepository.getReferenceById(postId);   // after existsById if you need a 404

// ── 7. Missing foreign key index on PostgreSQL ────────────────────────
// ✅ CREATE INDEX idx_comments_post_id ON comments (post_id);