What does the JPA @Lob annotation do to a String or byte[] entity attribute, and what problems do teams run into with it in production?
answer
- @Lob → CLOB/BLOB instead of varchar/varbinary
- PostgreSQL oid vs text trap; orphaned large objects
- selected with the row + snapshot copy = double memory
- unrelated update rewrites the whole LOB
- fix = separate table behind lazy one-to-one, or projections
basics
~20 s@Lob tells the provider to map the attribute to a large-object type — CLOB for String, BLOB for byte[] — instead of varchar or varbinary. In production it costs memory: the whole value is loaded with the row and rewritten on update unless you split it out.
solid answer
~50 s`@Lob` changes the JDBC type used for a basic attribute: `String` maps to a character large object, `byte[]`/`Byte[]` to a binary one. The exact SQL type depends on the dialect, and that is the first trap — on PostgreSQL, `@Lob` on a `String` has historically routed to the large-object (`oid`) mechanism rather than plain `text`, producing errors such as "Large Objects may not be used in auto-commit mode" and leaving orphaned objects behind. Teams normally map the column as `text`/`bytea` explicitly instead. The operational problems are the same for any dialect. The value is selected with the row, so every load of the entity drags megabytes into the heap; dirty checking keeps a second copy in the snapshot; and any update to any attribute rewrites the whole LOB unless dynamic updates are enabled. The durable fix is structural: move the LOB to its own entity and table behind a lazy one-to-one, query projections that omit it, or stream through `java.sql.Blob`/`Clob` locators rather than materialising a `byte[]`.
code
java · 22 lines@Entity
class Document {
@Id Long id;
String title;
@OneToOne(mappedBy = "document", fetch = FetchType.LAZY,
cascade = CascadeType.ALL, optional = false)
DocumentContent content;
}
@Entity
class DocumentContent {
@Id Long id;
@MapsId
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id")
Document document;
@Lob
byte[] bytes;
}go deeper
Know that @Lob maps large text or binary data to CLOB/BLOB types rather than bounded varchar or varbinary.
Explain that the value is fetched with the row, that lazy basic attributes need bytecode enhancement, and that the SQL type is dialect-dependent.
Cover the memory multipliers, whole-LOB rewrites on unrelated updates, the PostgreSQL large-object trap, and the table-splitting and projection fixes.
Weigh keeping bytes in the database at all against object storage, naming the trade between transactional consistency and row size, backup cost and replication load.
## What the annotation means Without `@Lob`, a `String` maps to `varchar` bounded by `@Column(length)` and a `byte[]` maps to a bounded binary type. `@Lob` says "this is large": the provider picks the dialect's large-object type — `clob`/`text` for characters, `blob`/`bytea` for bytes — and binds it through the JDBC LOB APIs rather than as a simple value. You can also declare the attribute as `java.sql.Blob` or `java.sql.Clob` rather than `byte[]`/`String`. That maps to a *locator*: a handle you stream from, instead of the whole value in memory. It is clumsier to work with and ties your code to JDBC types, but it is the only mapping that lets you process a large value without materialising it. ## The dialect trap The SQL type behind `@Lob` is dialect-chosen, and PostgreSQL is the classic sharp edge. Its large-object facility (`oid` plus the `pg_largeobject` catalogue) is a different mechanism from an inline `text`/`bytea` column: large objects live outside the row, require an open transaction to read or write, and are **not** deleted when the referencing row is deleted — you accumulate orphans until something calls the vacuum utility for them. Historically Hibernate mapped `@Lob String` on PostgreSQL to `oid`, which is why teams meet errors like "Large Objects may not be used in auto-commit mode" or "invalid large-object descriptor" the first time they read one outside a transaction. The usual resolution is to stop using `@Lob` for that attribute and map the column explicitly as `text` or `bytea` — via `columnDefinition`, or by declaring the JDBC type code the provider should use. Behaviour here has shifted between Hibernate versions and driver versions, so the practical rule is: **look at the generated DDL and the actual column type** rather than assuming. ## Memory and fetching A LOB attribute is a basic attribute, so by default it is part of the entity's `SELECT`. Load a hundred documents to list their titles and you have loaded a hundred document bodies. Three multipliers make this worse than it first looks: 1. **The snapshot.** The persistence context keeps a copy of the loaded state for dirty checking, so a 10 MB array is 20 MB while the entity is managed. 2. **The second-level cache**, if the entity is cached, holds another copy. 3. **Merge and detachment paths** copy again. `@Basic(fetch = FetchType.LAZY)` on the attribute is the naive fix, but it is only a hint: without build-time bytecode enhancement, Hibernate cannot intercept the field read and the column is fetched anyway. Even with enhancement, the deferred read is an extra query per entity, which turns a list screen into an N+1 the moment anything touches the field. ## Update cost Hibernate builds one `UPDATE` per entity type listing all updatable columns. Change the document's title and the statement still sets the body column to its current value — the whole LOB is re-sent over the wire and rewritten by the database, and on an MVCC engine a new row version is written regardless. Dynamic updates (generating the statement from the actually-changed attributes) avoid the re-send at the cost of statement-cache churn, which is a real trade rather than a free win. ## The patterns that actually work **Split the table.** Keep `Document` (id, title, metadata) and `DocumentContent` (id, bytes) as separate entities in separate tables, joined by a lazy one-to-one on the shared primary key. Now the LOB is never loaded unless the association is navigated, the update statement for the metadata never mentions it, and the fix requires no bytecode enhancement. This is the recommendation that survives version and dialect changes. **Project instead of loading entities.** For list screens, select a DTO or a tuple of the columns you need. No entity, no snapshot, no LOB. **Stream with locators.** Map `Blob`/`Clob` and stream to the response or to a file. Note that the locator is only valid while the transaction and connection are open, which shapes how the surrounding code must be written. **Or keep it out of the database.** Object storage with only a key in the row sidesteps LOB mechanics entirely, at the cost of losing transactional consistency between the row and the bytes — which is exactly the trade-off worth stating out loud in a design discussion. ## Quick diagnostic checklist - What SQL type did the DDL actually produce for this attribute? - Is the column in the `SELECT` of every list query that loads this entity? - Does an unrelated update re-send it? - Is there a second copy in the snapshot and a third in the cache? If the answers are uncomfortable, the table wants splitting.
- Why doesn't @Basic(fetch = FetchType.LAZY) reliably keep a LOB out of the SELECT?Lazy loading of a basic attribute requires the provider to intercept reads of that field, which Hibernate can only do with build-time bytecode enhancement; without it the setting is a hint that is ignored. Even with enhancement, the deferred read costs an extra query per entity when the attribute is touched, so a list screen can turn into an N+1. Splitting the column into a separate entity behind a lazy one-to-one achieves the same goal without depending on enhancement.
- What is the risk of mapping a large String with @Lob on PostgreSQL?The dialect has historically routed it to the large-object mechanism backed by an oid column rather than to an inline text column. Large objects live outside the row, need an open transaction to be read, and are not removed when the row is deleted, so you can accumulate orphans and hit auto-commit errors. Mapping the column explicitly as text avoids the whole mechanism.
saying these in an interview costs you the question
- Assuming @Lob makes the attribute lazily loaded by default.
- Assuming @Lob maps to text on every database, so the same mapping is portable.
- Believing an unrelated field update will not rewrite the LOB column.
- Forgetting the dirty-checking snapshot holds a second copy of the value in memory.
- Treating @Lob as a performance feature rather than a type declaration.