JPA Entity Mapping: @Entity, @Id, @Column, and More
JPA entity mapping defines how Java objects translate to database tables. Get it right and your schema is clean, performant, and expressive. This article covers every mapping annotation you’ll need.
@Entity and @Table
@Entity
@Table(
name = "orders", // table name (default: class name)
schema = "commerce", // database schema
indexes = {
@Index(name = "idx_orders_customer_id", columnList = "customer_id"),
@Index(name = "idx_orders_status_created", columnList = "status, created_at")
},
uniqueConstraints = {
@UniqueConstraint(name = "uq_order_number", columnNames = "order_number")
}
)
public class Order {
// ...
}
If @Table is omitted, Hibernate uses the class name as the table name (or the naming strategy-converted version, e.g. OrderItem → order_item).
@Id — Primary Key Strategies
UUID (recommended for distributed systems)
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Hibernate 6.2+: generates UUID v4 in Java before inserting
// → no DB round-trip to get the ID
// → safe to distribute, no sequence contention
Auto-increment (IDENTITY)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// DB auto-increment (SERIAL in PostgreSQL, AUTO_INCREMENT in MySQL)
// → requires DB round-trip after insert to retrieve the generated ID
// → doesn't work with batch inserts (Hibernate disables batching)
Sequence (best for high-throughput Long IDs)
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "orders_id_seq",
allocationSize = 50) // pre-allocate 50 IDs at a time
private Long id;
// → Hibernate pre-fetches 50 sequence values → minimal DB round-trips
// → works with batch inserts
// → default allocationSize=50 means the sequence increments by 50
Natural key (when you have one)
@Id
@Column(name = "order_number")
private String orderNumber;
// No @GeneratedValue — you set it yourself
// Use when your entity already has a natural unique identifier
@Column — Fine-Grained Column Mapping
@Column(
name = "customer_id", // column name (default: field name with naming strategy)
nullable = false, // NOT NULL constraint
unique = false, // UNIQUE constraint
length = 255, // VARCHAR length (only for String)
precision = 12, // decimal total digits
scale = 2, // decimal digits after point
insertable = true, // include in INSERT statements
updatable = false // exclude from UPDATE statements (immutable column)
)
private UUID customerId;
Common column configurations
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "order_number", nullable = false, unique = true, length = 20)
private String orderNumber;
@Column(name = "customer_id", nullable = false, updatable = false)
private UUID customerId;
// Enum: store as string (not ordinal — ordinals break when you reorder)
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status = OrderStatus.PENDING;
// Money: always use BigDecimal, never double/float
@Column(name = "total_amount", precision = 12, scale = 2)
private BigDecimal totalAmount = BigDecimal.ZERO;
// Timestamps: Instant for UTC points in time
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
// Large text
@Lob
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
// JSON as a string (or use a JSON column type)
@Column(name = "metadata", columnDefinition = "jsonb")
private String metadata;
// Version for optimistic locking
@Version
private Long version;
}
@Embedded — Value Objects
Use @Embedded to map a Java object to columns in the same table — no separate table needed:
@Embeddable
public class Address {
@Column(name = "address_line1", length = 100)
private String line1;
@Column(name = "address_line2", length = 100)
private String line2;
@Column(length = 50, nullable = false)
private String city;
@Column(length = 2, nullable = false)
private String countryCode;
@Column(name = "postal_code", length = 10, nullable = false)
private String postalCode;
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Embedded
private Address shippingAddress;
// The Address fields map to columns: address_line1, address_line2, city, country_code, postal_code
// All in the orders table — no JOIN needed
}
Override column names when embedding the same type multiple times:
@Entity
public class Customer {
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "line1", column = @Column(name = "billing_line1")),
@AttributeOverride(name = "city", column = @Column(name = "billing_city")),
// ...
})
private Address billingAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "line1", column = @Column(name = "shipping_line1")),
@AttributeOverride(name = "city", column = @Column(name = "shipping_city")),
// ...
})
private Address shippingAddress;
}
@ElementCollection — Simple Collections
For a collection of simple values or embeddables (not entities):
@Entity
public class Order {
@ElementCollection
@CollectionTable(
name = "order_tags",
joinColumns = @JoinColumn(name = "order_id")
)
@Column(name = "tag")
private Set<String> tags = new HashSet<>();
@ElementCollection
@CollectionTable(name = "order_promo_codes",
joinColumns = @JoinColumn(name = "order_id"))
@Column(name = "promo_code")
private List<String> appliedPromoCodes = new ArrayList<>();
}
This creates a separate order_tags table with order_id and tag columns — no separate entity class needed for simple values.
Converters — Custom Type Mapping
Map a Java type to a DB column type using @Converter:
// Map Money (a value object) to a single decimal column
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, BigDecimal> {
@Override
public BigDecimal convertToDatabaseColumn(Money money) {
return money == null ? null : money.getAmount();
}
@Override
public Money convertToEntityAttribute(BigDecimal column) {
return column == null ? null : Money.of(column, "USD");
}
}
// Map a custom status type
@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
@Override
public String convertToDatabaseColumn(OrderStatus status) {
return status == null ? null : status.getCode();
}
@Override
public OrderStatus convertToEntityAttribute(String code) {
return OrderStatus.fromCode(code);
}
}
With autoApply = true, the converter applies to all fields of that type automatically — no annotation needed on the field.
Inheritance Mapping
Three strategies for mapping a class hierarchy:
SINGLE_TABLE (default, most efficient)
All classes in one table. A discriminator column determines the type.
@Entity
@Table(name = "payments")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Payment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private BigDecimal amount;
private Instant processedAt;
}
@Entity
@DiscriminatorValue("CREDIT_CARD")
public class CreditCardPayment extends Payment {
private String cardLast4;
private String network; // VISA, MC, AMEX
}
@Entity
@DiscriminatorValue("BANK_TRANSFER")
public class BankTransferPayment extends Payment {
private String bankCode;
private String accountNumber;
}
Pros: One table, no JOINs, fast queries. Cons: subclass-specific columns are nullable in DB.
TABLE_PER_CLASS
Each concrete class gets its own table with all inherited columns repeated.
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment { ... }
// TABLE: credit_card_payments (id, amount, processed_at, card_last4, network)
@Entity
public class CreditCardPayment extends Payment { ... }
// TABLE: bank_transfer_payments (id, amount, processed_at, bank_code, account_number)
@Entity
public class BankTransferPayment extends Payment { ... }
Cons: Querying the parent type requires UNION ALL across all tables.
JOINED
Shared fields in parent table, subclass fields in their own tables.
@Entity
@Table(name = "payments")
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment { ... }
// TABLE: payments (id, amount, processed_at)
// TABLE: credit_card_payments (id FK, card_last4, network)
@Entity
@Table(name = "credit_card_payments")
public class CreditCardPayment extends Payment { ... }
Pros: Normalized schema, no nullable columns. Cons: Requires JOIN for every query.
Recommendation: Start with SINGLE_TABLE. Switch to JOINED only if subclass columns become too many.
@MappedSuperclass — Common Fields Without Inheritance
When you want to share fields (like audit fields) without JPA inheritance:
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
protected UUID id;
@CreatedDate
@Column(updatable = false)
protected Instant createdAt;
@LastModifiedDate
protected Instant updatedAt;
@Version
protected Long version;
}
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
// Gets id, createdAt, updatedAt, version from BaseEntity
// Each subclass gets its own table — no inheritance mapping
private UUID customerId;
private OrderStatus status;
}
@MappedSuperclass is not an entity itself — it has no table. The fields are included in each subclass table.
@Version — Optimistic Locking
Prevent lost updates with optimistic locking:
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Version
private Long version; // Hibernate manages this automatically
// other fields...
}
How it works:
- Load order with version=3
- Two requests modify the order concurrently
- First request saves:
UPDATE orders SET ... WHERE id=? AND version=3→ updates to version=4 → success - Second request tries:
UPDATE orders SET ... WHERE id=? AND version=3→ 0 rows updated →OptimisticLockException
Handle it:
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ProblemDetail> handleOptimisticLock(
ObjectOptimisticLockingFailureException ex,
HttpServletRequest request) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"The resource was modified by another request. Please retry."
);
p.setType(URI.create("https://devopsmonk.com/errors/optimistic-lock"));
p.setTitle("Concurrent Modification");
return ResponseEntity.status(HttpStatus.CONFLICT).body(p);
}
Best Practices
- Always use
@Enumerated(EnumType.STRING)— ordinals break when you add enum values - Use
BigDecimalfor money — neverdoubleorfloat(floating-point precision errors) - Use
Instantfor timestamps — always UTC, no timezone ambiguity - Add
@Indexfor columns you query or join on — foreign keys always need an index - Use
@Versionfor mutable entities — prevents silent lost updates under concurrency - Prefer UUID for IDs — distributed-safe, no sequence contention
- Keep entities in the domain layer — no Jackson annotations, no API concerns
What You’ve Learned
@Entity+@Tablemaps a class to a table; configure constraints and indexes here- ID generation: UUID (distributed-safe), IDENTITY (simple), SEQUENCE (high-throughput Long)
@Columncustomizes column name, nullability, length, precision@Embeddedmaps a value object to columns in the same table@ElementCollectionhandles collections of simple values without a separate entity@Convertermaps custom Java types to DB column types@Inheritancestrategies:SINGLE_TABLE(default),JOINED,TABLE_PER_CLASS@Versionenables optimistic locking — prevents lost updates under concurrent writes
Next: Article 17 — CRUD Operations with JpaRepository — save, find, update, delete, and batch operations in depth.