Spring Boot
OneToOne
@OneToOne links one entity row to exactly one row of another entity, with the foreign key on the owning side. This page covers when to use it (and when @Embedded or @ManyToOne is the better choice), unidirectional, bidirectional and shared-primary-key mappings, the real SQL Hibernate generates, lazy-loading limits on the inverse side, and the exceptions you will actually hit.
Before You Use @OneToOne: Is It Really a Separate Entity?
Many one-to-one relationships are not entities at all. A shipping address typed into an order has no identity or lifecycle of its own: nobody queries it independently and it dies with the order. For that, @Embedded keeps the columns in the same table, so there is no join, no extra SELECT and no lazy-loading trap.
Reach for @OneToOne when the other side genuinely has its own table and lifecycle: a large, rarely read profile (bio, avatar), data with different access rules (salary details), or an optional extension of the parent row. If the "one" side is shared by several parents, it is not one-to-one at all; use @ManyToOne (see the cascade section).
Java
import jakarta.persistence.*;
// ── Value object: lives in the SAME table, no join needed ────────────
@Embeddable
public class Address {
private String street;
private String city;
@Column(length = 2)
private String countryCode;
protected Address() { }
public Address(String street, String city, String countryCode) {
this.street = street;
this.city = city;
this.countryCode = countryCode;
}
public String getStreet() { return street; }
public String getCity() { return city; }
public String getCountryCode() { return countryCode; }
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "ship_street")),
@AttributeOverride(name = "city", column = @Column(name = "ship_city")),
@AttributeOverride(name = "countryCode", column = @Column(name = "ship_country_code"))
})
private Address shippingAddress;
protected Order() { }
}
// Resulting table - one row, zero joins:
// orders: id, ship_street, ship_city, ship_country_code
//
// Rule of thumb:
// no identity, no independent queries -> @Embedded
// own table, own lifecycle, 1:1 -> @OneToOne (rest of this page)
// shared by many parents -> @ManyToOneUnidirectional @OneToOne
In a unidirectional mapping only one entity knows about the other. The owning entity declares the relationship and carries the foreign key through @JoinColumn; the referenced entity has no field pointing back.
The unique = true on the join column matters: without it the database happily lets two orders point at the same address row, and your "one-to-one" is only a convention in Java code. With it, the database enforces the rule. Note the insert order in the log below: because the foreign key lives in orders, Hibernate must insert the address first.
Java
import jakarta.persistence.*;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String reference;
@OneToOne(
cascade = CascadeType.ALL, // save/delete the address with the order
fetch = FetchType.LAZY, // @OneToOne defaults to EAGER - override it
orphanRemoval = true // delete the address if it is detached from the order
)
@JoinColumn(
name = "shipping_address_id", // FK column in the orders table
nullable = false,
unique = true // DB-level guarantee of one-to-one
)
private ShippingAddress shippingAddress;
protected Order() { }
public Order(String reference, ShippingAddress address) {
this.reference = reference;
this.shippingAddress = address;
}
public Long getId() { return id; }
public String getReference() { return reference; }
public ShippingAddress getShippingAddress() { return shippingAddress; }
}
@Entity
@Table(name = "shipping_addresses")
public class ShippingAddress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) private String street;
@Column(nullable = false) private String city;
@Column(nullable = false, length = 2) private String countryCode;
protected ShippingAddress() { }
public ShippingAddress(String street, String city, String countryCode) {
this.street = street;
this.city = city;
this.countryCode = countryCode;
}
public Long getId() { return id; }
public String getStreet() { return street; }
}
// ── Usage ─────────────────────────────────────────────────────────────
@Transactional
public Order placeOrder() {
Order order = new Order("ORD-1001",
new ShippingAddress("1 Marina Walk", "Dubai", "AE"));
return orderRepository.save(order); // one save cascades to the address
}
// ── SQL Hibernate runs (spring.jpa.show-sql=true) ─────────────────────
// insert into shipping_addresses (city, country_code, street) values (?, ?, ?)
// insert into orders (reference, shipping_address_id) values (?, ?)
//
// ── DDL (excerpt) ─────────────────────────────────────────────────────
// orders.shipping_address_id bigint not null unique
// references shipping_addresses (id)Bidirectional @OneToOne
A bidirectional mapping lets you navigate from both sides, but there is still only one foreign key. One side owns it (the side with @JoinColumn); the other side is the inverse and declares mappedBy with the name of the owning field. Getting this wrong is the most common one-to-one bug: if the inverse side also declares its own @JoinColumn, Hibernate maps a second, nonexistent column.
Keep both Java references in sync with a single helper method, and never expose these entities directly from a REST controller: the User -> UserProfile -> User cycle will make Jackson recurse forever. Return a DTO instead.
Java
import jakarta.persistence.*;
// ── Owning side: has @JoinColumn, so users.profile_id is the FK ───────
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY,
orphanRemoval = true)
@JoinColumn(name = "profile_id", unique = true)
private UserProfile profile;
// Always set both sides through this method:
public void setProfile(UserProfile profile) {
this.profile = profile;
if (profile != null) {
profile.setUser(this);
}
}
protected User() { }
public User(String email) { this.email = email; }
public Long getId() { return id; }
public String getEmail() { return email; }
public UserProfile getProfile() { return profile; }
}
// ── Inverse side: mappedBy points at User.profile, NO @JoinColumn ─────
@Entity
@Table(name = "user_profiles")
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
@OneToOne(mappedBy = "profile", fetch = FetchType.LAZY)
private User user;
protected UserProfile() { }
public UserProfile(String bio, String avatarUrl) {
this.bio = bio;
this.avatarUrl = avatarUrl;
}
// Package-private: only User.setProfile() should call this
void setUser(User user) { this.user = user; }
public Long getId() { return id; }
public String getBio() { return bio; }
public User getUser() { return user; }
}
// ── Service ───────────────────────────────────────────────────────────
@Transactional
public UserResponse createUserWithProfile(CreateUserRequest req) {
User user = new User(req.email());
user.setProfile(new UserProfile(req.bio(), req.avatarUrl()));
return UserResponse.from(userRepository.save(user)); // return a DTO
}
// ── Resulting schema: only ONE foreign key ────────────────────────────
// users: id, email, profile_id (unique, FK -> user_profiles.id)
// user_profiles: id, bio, avatar_url <- no user_id column
//
// ── SQL on save ───────────────────────────────────────────────────────
// insert into user_profiles (avatar_url, bio) values (?, ?)
// insert into users (email, profile_id) values (?, ?)Shared Primary Key with @MapsId
With a shared primary key, the dependent table has no foreign key column of its own: its primary key is the parent's primary key. employee_details.id IS employees.id. This avoids an extra column and index, and it lets you load the detail by the parent's id without a join.
The dependent entity owns the relationship and declares @MapsId; Hibernate copies the parent's id into it when saving. Do not assign the id yourself and do not put @GeneratedValue on the dependent: the id does not exist yet when you wire the objects together, and @MapsId fills it in at flush time. Use this pattern when the detail row cannot exist without its parent.
Java
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "employees")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
// Inverse side: the detail entity owns the key
@OneToOne(mappedBy = "employee", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private EmployeeDetail detail;
public void setDetail(EmployeeDetail detail) {
this.detail = detail;
if (detail != null) detail.setEmployee(this);
}
protected Employee() { }
public Employee(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public EmployeeDetail getDetail() { return detail; }
}
@Entity
@Table(name = "employee_details")
public class EmployeeDetail {
@Id // no @GeneratedValue: id comes from Employee
private Long id;
private String department;
private String officeLocation;
private BigDecimal salary;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // employee_details.id = employees.id
@JoinColumn(name = "id")
private Employee employee;
protected EmployeeDetail() { }
public EmployeeDetail(String department, String location, BigDecimal salary) {
this.department = department;
this.officeLocation = location;
this.salary = salary;
}
void setEmployee(Employee employee) {
this.employee = employee; // do NOT copy the id by hand
}
public Long getId() { return id; }
public String getDepartment() { return department; }
public Employee getEmployee() { return employee; }
}
// ── Usage ─────────────────────────────────────────────────────────────
@Transactional
public Employee hire(String name) {
Employee e = new Employee(name);
e.setDetail(new EmployeeDetail("Platform", "Dubai", new BigDecimal("12000")));
return employeeRepository.save(e);
}
// ── SQL ───────────────────────────────────────────────────────────────
// insert into employees (name) values (?)
// insert into employee_details (department, office_location, salary, id) values (?, ?, ?, ?)
//
// ── Schema ────────────────────────────────────────────────────────────
// employees: id (PK), name
// employee_details: id (PK and FK -> employees.id), department, office_location, salaryFetch Strategy: Eager Default and the N+1 Problem
@OneToOne defaults to FetchType.EAGER. A single findById still loads the association with a join, so that case is fine. The damage appears with queries: findAll() or any JPQL query returns the parents first, then Hibernate fires one extra SELECT per parent to load each eager association. 100 users means 1 + 100 = 101 queries.
Declare FetchType.LAZY on every @OneToOne, then fetch the association explicitly in the queries that need it, with JOIN FETCH or an @EntityGraph.
One limit to know: only the owning side (the one with @JoinColumn) can be a true lazy proxy. On the inverse side (mappedBy), Hibernate must query to learn whether the other row exists or is null, so it loads it immediately even with LAZY. If you navigate mostly from one direction, put the foreign key on the other side, or use bytecode enhancement.
Java
// ── application.properties: watch the queries yourself ────────────────
// spring.jpa.show-sql=true
// logging.level.org.hibernate.SQL=DEBUG
// ── Default EAGER: N+1 on findAll() ───────────────────────────────────
@OneToOne(cascade = CascadeType.ALL) // EAGER by default
@JoinColumn(name = "profile_id")
private UserProfile profile;
// userRepository.findAll() with 3 users logs:
// select u.id, u.email, u.profile_id from users u
// select p.id, p.bio from user_profiles p where p.id=? (x3)
// ── Fix part 1: LAZY ──────────────────────────────────────────────────
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;
// findAll() now runs 1 query; profile loads only when you call getProfile()
// (inside a transaction, otherwise: LazyInitializationException)
// ── Fix part 2: fetch the association only where it is needed ─────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile WHERE u.id = :id")
Optional<User> findByIdWithProfile(@Param("id") Long id);
// Same result without writing JPQL:
@EntityGraph(attributePaths = "profile")
List<User> findAll();
}
// Log with JOIN FETCH / EntityGraph - one query for everything:
// select u.id, u.email, p.id, p.bio
// from users u left join user_profiles p on p.id = u.profile_idCascade and orphanRemoval
Cascade decides which operations on the parent are forwarded to the child. orphanRemoval = true is a separate switch: it deletes the child row when it is detached from the parent, not only when the parent is deleted.
Use both only when the child is truly owned by the parent (a profile belongs to one user). Do not use them when the child can exist independently or be shared. A classic mistake is a billing address mapped with @OneToOne plus orphanRemoval: the day a customer changes address, the old row is deleted even if another record still needs it. And if several parents really can point at the same child, the mapping is @ManyToOne, because a unique foreign key makes a second parent impossible.
Java
// ── Child owned by parent: cascade everything + orphanRemoval ─────────
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY,
orphanRemoval = true)
@JoinColumn(name = "profile_id", unique = true)
private UserProfile profile;
userRepository.delete(user);
// delete from users where id=?
// delete from user_profiles where id=? (cascade REMOVE)
user.setProfile(null); // inside a transaction
// update users set profile_id=null where id=?
// delete from user_profiles where id=? (orphanRemoval)
// Same mapping WITHOUT orphanRemoval:
user.setProfile(null);
// update users set profile_id=null where id=?
// ...the user_profiles row stays behind as an orphan
// ── Child that can outlive or be shared: do NOT use @OneToOne ─────────
// Many orders may reference the same saved address:
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "billing_address_id") // no unique = true
private Address billingAddress;
// No cascade REMOVE and no orphanRemoval: deleting an order
// must not delete an address other orders still use.Common Errors and How to Fix Them
These are the three failures people hit most often with @OneToOne, each with the cause and the fix.
TransientPropertyValueException means you are saving a parent that points at a child that has not been persisted, and no cascade covers it. LazyInitializationException means you touched a lazy association after the transaction closed. A unique constraint violation on the foreign key means two parents were pointed at the same child, which a one-to-one forbids by design.
Java
// ── 1. TransientPropertyValueException ────────────────────────────────
// object references an unsaved transient instance - save the transient
// instance before flushing: Order.shippingAddress -> ShippingAddress
//
// Cause: no cascade, child never saved.
// Fix A: add cascade = CascadeType.PERSIST (or ALL) to the @OneToOne
// Fix B: save the child first:
shippingAddressRepository.save(address);
orderRepository.save(new Order("ORD-1002", address));
// ── 2. LazyInitializationException ────────────────────────────────────
// could not initialize proxy [UserProfile#5] - no Session
//
// Cause: getProfile().getBio() called after the transaction ended.
// Fix: load what you need inside the transaction, or fetch it up front:
@Transactional(readOnly = true)
public String bioFor(Long userId) {
return userRepository.findByIdWithProfile(userId)
.map(u -> u.getProfile().getBio())
.orElseThrow();
}
// ── 3. Unique constraint violation on profile_id ──────────────────────
// Unique index or primary key violation: users(profile_id)
//
// Cause: two users were assigned the same UserProfile instance.
// Fix: give each user its own profile. If sharing is intended, the
// relationship is not one-to-one; change it to @ManyToOne.
user2.setProfile(new UserProfile(bio, avatar)); // not user1.getProfile()