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. OrderItemorder_item).

@Id — Primary Key Strategies

@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:

  1. Load order with version=3
  2. Two requests modify the order concurrently
  3. First request saves: UPDATE orders SET ... WHERE id=? AND version=3 → updates to version=4 → success
  4. 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

  1. Always use @Enumerated(EnumType.STRING) — ordinals break when you add enum values
  2. Use BigDecimal for money — never double or float (floating-point precision errors)
  3. Use Instant for timestamps — always UTC, no timezone ambiguity
  4. Add @Index for columns you query or join on — foreign keys always need an index
  5. Use @Version for mutable entities — prevents silent lost updates under concurrency
  6. Prefer UUID for IDs — distributed-safe, no sequence contention
  7. Keep entities in the domain layer — no Jackson annotations, no API concerns

What You’ve Learned

  • @Entity + @Table maps a class to a table; configure constraints and indexes here
  • ID generation: UUID (distributed-safe), IDENTITY (simple), SEQUENCE (high-throughput Long)
  • @Column customizes column name, nullability, length, precision
  • @Embedded maps a value object to columns in the same table
  • @ElementCollection handles collections of simple values without a separate entity
  • @Converter maps custom Java types to DB column types
  • @Inheritance strategies: SINGLE_TABLE (default), JOINED, TABLE_PER_CLASS
  • @Version enables optimistic locking — prevents lost updates under concurrent writes

Next: Article 17 — CRUD Operations with JpaRepository — save, find, update, delete, and batch operations in depth.