Key Takeaways
- A Spring Data JPA one-to-one relationship can use a shared primary key, a unique foreign key, or a join table.
- The owning side writes the association; the inverse side names that Java field with
mappedBy. Keep both references synchronized. @MapsIdties the dependent entity’s identity to its parent. It can eliminate a separate identifier column, but it isn’t universally the best mapping.- Enforce cardinality with database constraints, and verify fetching with SQL rather than assuming
EAGERmeans one query.
To map a Spring Data JPA one-to-one relationship, annotate the association with @OneToOne, choose where the relationship lives in the schema, and enforce uniqueness in the database. The three options below use a shared primary key, a direct foreign key, and a join table.
We’ll keep Author and Book as a deliberately simplified model: an author can be linked to at most one book, and a book to at most one author. If your authors write multiple books, use a one-to-many relationship instead.
Setup and Mapping Choices
The example baseline is Java 21, Spring Boot 4.0, Spring Data JPA 4.0, and Jakarta Persistence 3.2. Keep dependency versions managed by Spring Boot; its dependency coordinates identify the compatible releases.
Create a Maven application using Spring Initializr, selecting Spring Data JPA and H2 Database. Keep the generated application class, put the following Java files in its package, and use these dependencies in the generated POM. The test dependency below is the Spring Boot 4 JPA test starter.
<!-- Fragment: dependencies inside the generated Spring Boot POM. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>Use jakarta.persistence imports for this stack. Older Spring Boot 2 examples use javax.persistence; the Spring Boot 3 migration guide explains the namespace transition. If repositories are new to you, start with the Spring Data JPA CRUD example.
| Mapping | Relationship storage | Owner here | Choose it when |
|---|---|---|---|
| Shared primary key | book.author_id is both PK and FK | Book.author | The book’s identity depends on its author |
| Unique foreign key | author.book_id references book.id | Author.book | Entities need independent identities |
| Join table | author_book contains two unique FKs | Author.book | An existing schema or separate association table calls for it |
Owning Side Versus Inverse Side
The owning side determines relationship updates in the database. With a direct FK, it’s the entity whose table contains that FK; with a join table, it’s the association declaring @JoinTable. The inverse side uses mappedBy, which names a Java field or property—not a database column. Updating only the inverse reference can leave the database association unchanged.
Ownership doesn’t decide which entities get inserted. Explicit persistence operations and cascade configuration do that. Use a unidirectional mapping if you don’t need navigation in both directions; the second Java reference isn’t required to represent the relationship.
1) @OneToOne Example Using a Shared Primary Key
Here, Book depends on Author. Its primary key comes from the author through @MapsId, and the same column is also a foreign key. No separate ID generator belongs on Book.
author book
id [PK] <--------------------- author_id [PK, FK]
Owner: Book.author
Inverse: Author.book -- mappedBy="author" --> Book.author
Lifecycle: persist Author --> derive Book.id --> persist BookAuthor.java — complete entity; replace the package name with your generated application’s package.
package com.example.onetoone;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.util.Objects;
@Entity
@Table(name = "author")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "author", fetch = FetchType.LAZY)
private Book book;
protected Author() {}
public Long getId() { return id; }
public Book getBook() { return book; }
public void attachBook(Book book) {
Objects.requireNonNull(book, "book");
if (this.book != null || book.getAuthor() != null) {
throw new IllegalStateException("Author or book already linked");
}
this.book = book;
book.attachTo(this);
}
}Book.java
package com.example.onetoone;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "book")
public class Book {
@Id
private Long id;
@MapsId
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "author_id", nullable = false)
private Author author;
protected Book() {}
public Long getId() { return id; }
public Author getAuthor() { return author; }
void attachTo(Author author) {
this.author = author;
}
}The helper updates both Java references and intentionally supports initial attachment only. Don’t reassign an existing shared-PK book to another author: that would change its persistent identity. @PrimaryKeyJoinColumn isn’t needed on the inverse field; @MapsId expresses the derived identity here.
Persist the Parent Before the Dependent
Create these repositories as separate files. The explicit fetch query is explained in the fetching section below; Spring Data supports @Query with named @Param parameters.
// AuthorRepository.java
package com.example.onetoone;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("select a from Author a left join fetch a.book where a.id = :id")
Optional<Author> findWithBookById(@Param("id") Long id);
}// BookRepository.java
package com.example.onetoone;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {}The service saves both entities explicitly, without cascades. Its transaction covers both repository calls.
package com.example.onetoone;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
private final AuthorRepository authorRepo;
private final BookRepository bookRepo;
public CatalogService(AuthorRepository authorRepo, BookRepository bookRepo) {
this.authorRepo = authorRepo;
this.bookRepo = bookRepo;
}
@Transactional
public Long createPair() {
Author author = authorRepo.save(new Author());
Book book = new Book();
author.attachBook(book);
bookRepo.save(book);
return author.getId();
}
}Illustrative rows after commit—not promised generated ID values:
author book
id author_id
24 24Why Shared Identity Isn’t Always Best
This schema avoids a separate book.id column alongside an author_id FK. The trade-off is identity coupling: a book cannot exist without its author, while an author can exist without a book. A smaller schema alone doesn’t prove faster queries or lower operating costs; choose this design for the lifecycle, then measure your workload.
2) @OneToOne Example Using a Unique Foreign Key
For independent identities, keep a generated ID on each entity and put book_id on author. Starting from the previous entities, replace the fields shown below; keep the constructors, getters, and attachment helper.
// Fragments: replacement fields inside the existing entities.
// Author.java: add import jakarta.persistence.JoinColumn;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", unique = true, nullable = true)
private Book book;
// Book.java: add imports for GeneratedValue and GenerationType.
// Remove @MapsId and the old owning association mapping.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "book", fetch = FetchType.LAZY)
private Author author;author book
id [PK] id [PK]
book_id [FK, UNIQUE, nullable] ------> id
Owner: Author.book
Inverse: Book.author -- mappedBy="book" --> Author.bookA foreign key enforces a valid reference; uniqueness prevents multiple authors from referencing the same book. JPA’s default one-to-one FK mapping includes a unique constraint, but an annotation doesn’t retrofit an independently managed database. Explicit unique = true makes the intent visible; verify the actual migration and constraints.
Run the Foreign-Key Example
Replace the body of CatalogService.createPair() with this fragment. Persist the book before the author that references it; the helper still updates both sides.
// Fragment: replacement createPair() body, inside its existing transaction.
Book book = bookRepo.save(new Book());
Author author = new Author();
author.attachBook(book);
return authorRepo.save(author).getId();book author
id id | book_id
30 45 | 30The extra FK column stores the association; it isn’t caused by bidirectional navigation. Unlike the shared-PK version, the book has its own identity, and the nullable FK allows an author without a book.
3) @OneToOne Example Using a Join Table
Keep the independently generated IDs and Book.author from option 2. Replace only Author.book with this mapping, adding the jakarta.persistence.JoinTable import.
// Fragment: replacement Author.book field.
@OneToOne(fetch = FetchType.LAZY)
@JoinTable(
name = "author_book",
joinColumns = @JoinColumn(
name = "author_id", referencedColumnName = "id",
nullable = false, unique = true
),
inverseJoinColumns = @JoinColumn(
name = "book_id", referencedColumnName = "id",
nullable = false, unique = true
)
)
private Book book;author author_book book
id [PK] <-------------- author_id [FK, UNIQUE, NN]
book_id [FK, UNIQUE, NN] --> id [PK]
NN = NOT NULL
Owner: Author.book
Inverse: Book.author -- mappedBy="book" --> Author.bookjoinColumns references the owning entity; inverseJoinColumns references the associated entity. Each FK is individually unique. A unique constraint on only the pair would still allow the same author or book in multiple associations.
Run the Join-Table Example
Use the same transactional createPair() body as option 2: save the book, attach both references, and save the author. Illustrative rows are:
author book author_book
id id author_id | book_id
14 10 14 | 10Why Use a Join Table?
A join table is useful when you must match an existing schema or keep association storage outside either entity table. An absent relationship can be represented by no join row instead of a nullable entity-table FK. The cost is another table, another association write, and a longer join path when fetching the pair.
Optionality, Constraints, and Lifecycle
For a mandatory direct-FK association, replace the option 2 field with:
// Fragment: mandatory Author.book field.
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "book_id", unique = true, nullable = false)
private Book book;optional = falsedeclares that the entity association must be non-null.nullable = falsedeclares that the mapped FK column is non-nullable.- Actual
NOT NULL,UNIQUE, and FK constraints enforce those rules for database writes, including concurrent writers and clients outside your application.
Keep the mapping and database aligned. Generated DDL varies by provider and database; don’t rely on a particular exception type or on the error appearing at the save() call rather than flush or commit.
Cascade and Orphan Removal Are Different Decisions
No cascades are enabled in these examples. CascadeType.PERSIST propagates persistence, while CascadeType.ALL also includes MERGE, REMOVE, REFRESH, and DETACH. Adding ALL can therefore make deleting an author delete its book through that association. See the cascade operation definitions.
orphanRemoval = true schedules removal of a managed dependent when it’s disconnected from the association. Use it for privately owned dependents, not as a convenient default. Cascade direction matters: a cascade on Author.book isn’t traversed merely because you save Book.
Fetching: EAGER Doesn’t Mean One Query
@OneToOne defaults to FetchType.EAGER; LAZY is a provider hint. Eager loading requires the association to be fetched, but Hibernate can satisfy that through joins or additional selects. Loading a list can therefore produce N+1 queries: one list query followed by extra association queries.
The examples request LAZY, but inverse one-to-one loading needs special attention. Hibernate may need to query the dependent table to determine whether a row exists; reliable lazy loading in some bidirectional scenarios requires bytecode enhancement. Check the Hibernate association-fetching documentation for your provider release.
The repository’s left join fetch a.book explicitly fetches the book while retaining authors without one. Spring Data’s @EntityGraph(attributePaths = "book") is another fetch-plan option, not a portable guarantee of one SQL statement.
Repository findById() returns an Optional, not an entity. Handle the missing row explicitly and access lazy state within the intended transaction:
// Fragment: inside a transactional reader with authorRepo and id supplied.
Author author = authorRepo.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Unknown author: " + id));
Book book = author.getBook(); // May be null; may require another SQL query.How @OneToOne Works Behind the Scenes
Jakarta Persistence, still commonly called JPA, defines mapping annotations and persistence semantics. Hibernate is an object-relational mapping provider that implements them; Spring Data JPA adds repository abstractions on top. These are different layers, as explained in Spring Data JPA versus Hibernate.
Hibernate builds mapping metadata, tracks managed entity changes, and translates persistence operations and queries into SQL. Schema creation is a separate configuration choice—not an automatic consequence of adding an annotation.
Besides eager fetching, the annotation defaults are optional = true, no cascades, orphanRemoval = false, and an empty mappedBy. Set the options that express your domain rather than copying every option into every relationship.
Verify the Mapping Before Shipping
For option 1, put this test under src/test/java in the same package. It flushes the inserts, clears the persistence context, and reloads the pair rather than merely checking the objects already in memory. Spring Boot’s @DataJpaTest logs SQL by default and rolls back test transactions.
package com.example.onetoone;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
@DataJpaTest
@Import(CatalogService.class)
class SharedPrimaryKeyTest {
@Autowired CatalogService catalog;
@Autowired AuthorRepository authorRepo;
@PersistenceContext EntityManager entityManager;
@Test
void persistsAndReloadsSharedIdentity() {
Long id = catalog.createPair();
entityManager.flush();
entityManager.clear();
Author author = authorRepo.findWithBookById(id).orElseThrow();
assertEquals(id, author.getBook().getId());
assertSame(author, author.getBook().getAuthor());
}
}- Inspect the generated schema for the PK, FK, uniqueness, and nullability shown in each diagram.
- For option 2, test duplicate non-null
author.book_idvalues. For option 3, test duplicate values in each join column separately. The database should reject them. - Test allowed missing associations, disallowed missing associations, and your chosen deletion behavior.
- Count SQL statements when fetching both one pair and a list; don’t infer query count from annotations.
- Run constraint tests against your production database engine, not only the demonstration H2 database.
FAQ
Which side owns a JPA @OneToOne relationship?
For a direct foreign-key mapping, the entity whose table contains the FK owns the relationship. For a join-table mapping, the association declaring @JoinTable owns it. The inverse side uses mappedBy to name the owning Java field or property.
Does @OneToOne automatically create a unique foreign key?
The default JPA one-to-one foreign-key mapping includes a unique constraint when the provider generates the schema. It doesn’t add constraints to an independently managed schema by itself; declare the intended uniqueness explicitly and verify your database migration.
How do I map a shared-primary-key one-to-one relationship with @MapsId?
Give the dependent entity an @Id and annotate its parent association with @MapsId and @JoinColumn. Don’t generate a separate dependent ID. Persist the parent first, or configure an appropriate persistence cascade, and assign the parent reference before persisting the dependent.
Does FetchType.EAGER guarantee one SQL query?
No. EAGER requires the association to be fetched, but it doesn’t prescribe the SQL strategy or number of round trips. Use an explicit fetch plan and verify the generated SQL for your query and provider.
What is the difference between optional=false and nullable=false?
optional=false declares a mandatory entity association; nullable=false declares a non-nullable mapped join column. Keep both aligned with actual database constraints. Neither a non-null join-table column nor an inverse-side annotation alone guarantees that every parent has an association row.
Further Reading
- Jakarta Persistence 3.2 specification — ownership, derived identity, defaults, and lifecycle semantics.
- Spring Boot managed dependency coordinates — compatible persistence dependencies.
- Hibernate ORM User Guide — one-to-one mappings and lazy-loading limitations.
- Spring Data JPA query methods — declared queries and entity graphs.
- Spring Data JPA transactionality — transaction boundaries across repositories.
- Spring Boot DataJpaTest API — focused persistence testing.
- PostgreSQL constraint documentation — concrete explanations of unique, foreign-key, and non-null constraints.
