One-to-One Relationships: @OneToOne in Depth
Introduction
A one-to-one relationship means one record in table A corresponds to exactly one record in table B. In JPA this is modelled with @OneToOne. Understanding where the foreign key lives, which side “owns” the relationship, and how cascade operations work is essential before moving to the more complex @OneToMany and @ManyToMany.
The Domain Example
In the e-commerce system, a Customer has one CustomerProfile containing their preferences and biography. A profile belongs to exactly one customer.
customers ──── customer_profiles
(1) (1)
Unidirectional @OneToOne
The simplest form: only one side knows about the relationship.
@Entity
@Table(name = "customer_profiles")
@Getter @Setter @NoArgsConstructor
public class CustomerProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "TEXT")
private String bio;
@Column(name = "newsletter_opt_in")
private boolean newsletterOptIn = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}
@Entity
@Table(name = "customers")
@Getter @Setter @NoArgsConstructor
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, length = 150, unique = true)
private String email;
// Unidirectional @OneToOne — Customer knows about Profile, but not vice versa
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id", unique = true)
private CustomerProfile profile;
}
The @JoinColumn(name = "profile_id") places a foreign key column profile_id in the customers table pointing to customer_profiles.id.
customers table:
id | name | email | profile_id
1 | Alice | alice@.. | 1
Creating a customer with profile
@Transactional
public Customer createCustomer(String name, String email, String bio) {
CustomerProfile profile = new CustomerProfile();
profile.setBio(bio);
profile.setCreatedAt(LocalDateTime.now());
Customer customer = new Customer();
customer.setName(name);
customer.setEmail(email);
customer.setProfile(profile); // associate
customer.setCreatedAt(LocalDateTime.now());
customer.setUpdatedAt(LocalDateTime.now());
// CascadeType.ALL on profile → save(customer) also saves profile
return customerRepository.save(customer);
}
Hibernate generates:
INSERT INTO customer_profiles (bio, newsletter_opt_in, created_at) VALUES (?, ?, ?)
INSERT INTO customers (name, email, profile_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
Bidirectional @OneToOne
In a bidirectional relationship, both sides have a reference to the other. One side owns the relationship (holds the foreign key); the other uses mappedBy.
@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private boolean newsletterOptIn;
// Inverse side — mappedBy points to the field in Customer that owns the FK
@OneToOne(mappedBy = "profile")
private Customer customer;
}
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Owner side — this side has the @JoinColumn (the FK)
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", unique = true)
private CustomerProfile profile;
}
The ownership rule
- The side with
@JoinColumnis the owner — it holds the foreign key column - The side with
mappedByis the inverse — it references the owner’s field
Only changes on the owner side are persisted. Setting profile.setCustomer(customer) alone will not update the database — you must also set customer.setProfile(profile).
Helper methods for bidirectional consistency
Always maintain both sides of a bidirectional relationship:
@Entity
public class Customer {
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", unique = true)
private CustomerProfile profile;
public void setProfile(CustomerProfile profile) {
this.profile = profile;
if (profile != null) {
profile.setCustomer(this); // keep both sides in sync
}
}
}
Shared Primary Key: @PrimaryKeyJoinColumn
An alternative to a foreign key column is sharing the primary key. The CustomerProfile row uses the same id as its Customer row:
customers: id=1, name="Alice"
customer_profiles: id=1 (same id — FK to customers.id), bio="..."
@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {
@Id
private Long id; // same value as Customer.id
@OneToOne(fetch = FetchType.LAZY)
@PrimaryKeyJoinColumn // profile.id = customer.id
@MapsId // Hibernate 6 preferred style
private Customer customer;
private String bio;
}
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private CustomerProfile profile;
}
Shared primary key saves a foreign key column and ensures a profile can never exist without its customer (no orphan profile_id column).
Optional vs. Required @OneToOne
By default, @OneToOne is optional (optional = true) — the foreign key column can be NULL, and the associated entity may not exist.
// Optional (default) — profile_id can be NULL
@OneToOne(cascade = CascadeType.ALL, optional = true)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;
Set optional = false when the association is mandatory. Hibernate optimises the JOIN to an INNER JOIN instead of LEFT OUTER JOIN:
// Required — every customer MUST have a profile
@OneToOne(cascade = CascadeType.ALL, optional = false)
@JoinColumn(name = "profile_id", nullable = false)
private CustomerProfile profile;
Fetch Type: Lazy vs Eager
By default, @OneToOne is EAGER — when you load a Customer, Hibernate immediately loads the CustomerProfile too.
// EAGER (default for @OneToOne) — always loads profile with customer
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;
This means loading 1000 customers runs 1000 + 1000 = 2000 queries (one per customer plus one per profile). Use LAZY:
// LAZY — profile loaded only when you call customer.getProfile()
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private CustomerProfile profile;
Always use FetchType.LAZY on @OneToOne unless you always need both entities and have a single load pattern.
Cascade Types
cascade propagates persistence operations from the owning entity to the associated entity.
@OneToOne(cascade = CascadeType.ALL)
Common options:
| Cascade | Effect |
|---|---|
CascadeType.PERSIST | save(customer) also saves profile |
CascadeType.MERGE | merge(customer) also merges profile |
CascadeType.REMOVE | delete(customer) also deletes profile |
CascadeType.ALL | All of the above (+ REFRESH, DETACH) |
Use CascadeType.ALL for owned entities (profile belongs entirely to customer — no meaning without it). Be careful with CascadeType.REMOVE — accidentally deleting a shared entity is a common bug.
Complete Working Example
Flyway migration:
-- V2__add_customer_profiles.sql
CREATE TABLE customer_profiles (
id BIGINT NOT NULL AUTO_INCREMENT,
bio TEXT,
newsletter_opt_in TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
ALTER TABLE customers
ADD COLUMN profile_id BIGINT UNIQUE,
ADD CONSTRAINT fk_customer_profile
FOREIGN KEY (profile_id) REFERENCES customer_profiles (id);
Service:
@Service
@RequiredArgsConstructor
public class CustomerService {
private final CustomerRepository customerRepository;
@Transactional
public Customer register(String name, String email) {
CustomerProfile profile = new CustomerProfile();
profile.setBio("");
profile.setNewsletterOptIn(false);
profile.setCreatedAt(LocalDateTime.now());
Customer customer = new Customer();
customer.setName(name);
customer.setEmail(email);
customer.setProfile(profile);
customer.setCreatedAt(LocalDateTime.now());
customer.setUpdatedAt(LocalDateTime.now());
return customerRepository.save(customer);
}
@Transactional
public void updateBio(Long customerId, String bio) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.getProfile().setBio(bio); // loads profile (lazy), sets bio
// dirty checking picks up the profile change — no explicit save needed
}
}
Key Takeaways
@OneToOnemaps a one-to-one relationship; the side with@JoinColumnowns the foreign keymappedByon the inverse side points to the field on the owner side — it does not create a column- Always set both sides of a bidirectional relationship, or use a helper method
- Use
FetchType.LAZY— the defaultEAGERcauses unnecessary queries when loading collections optional = falsegenerates an INNER JOIN instead of LEFT OUTER JOIN — use it when the relationship is mandatoryCascadeType.ALLon owned entities means save/delete operations cascade automatically
What’s Next
Article 10 covers @OneToMany and @ManyToOne — the most common relationship type in every domain model, with bidirectional mapping, the parent-child pattern, and cascade/orphanRemoval.