JSONB lets you store flexible data alongside relational data — no separate document store needed. Learn how to use PostgreSQL JSONB with Spring Boot JPA and native queries.
JOptimize Team
Every application has some data that doesn't fit neatly into columns. Product attributes vary by category — electronics have voltage and frequency; clothing has size and material. User preferences are open-ended. Audit logs need to capture arbitrary before/after state. The traditional answer is an EAV (Entity-Attribute-Value) table, which is notoriously painful to query. A separate document database adds operational complexity. PostgreSQL's JSONB type is a third option that's often better than both.
JSONB stores JSON as a binary format that supports indexing, operators, and functions — not just string storage. You can query inside the JSON, index specific paths, and mix relational and document-style data in the same table.
JSONB makes sense when:
JSONB is the wrong choice when:
Hibernate 6+ supports JSONB natively on PostgreSQL:
<!-- pom.xml --> <dependency> <groupId>io.hypersistence</groupId> <artifactId>hypersistence-utils-hibernate-63</artifactId> <version>3.8.2</version> </dependency>
@Entity @Table(name = "products") public class Product { @Id @GeneratedValue private Long id; private String name; private String category; private BigDecimal price; // JSONB column — stores arbitrary attributes per product @Type(JsonBinaryType.class) @Column(columnDefinition = "jsonb") private Map<String, Object> attributes; // Or use a typed record for known structure @Type(JsonBinaryType.class) @Column(columnDefinition = "jsonb") private ProductMetadata metadata; } // Typed JSONB value object public record ProductMetadata( List<String> tags, Map<String, String> specs, boolean available, String countryOfOrigin ) {}
PostgreSQL provides a rich set of JSONB operators:
@Repository public interface ProductRepository extends JpaRepository<Product, Long> { // Query on a JSON string field using ->> operator @Query(value = """ SELECT * FROM products WHERE attributes->>'brand' = :brand AND category = :category """, nativeQuery = true) List<Product> findByCategoryAndBrand(@Param("category") String category, @Param("brand") String brand); // Query on a JSON numeric field @Query(value = """ SELECT * FROM products WHERE (attributes->>'voltage')::numeric BETWEEN :minV AND :maxV """, nativeQuery = true) List<Product> findByVoltageRange(@Param("minV") double min, @Param("maxV") double max); // Check JSON array contains a value using @> operator @Query(value = """ SELECT * FROM products WHERE attributes @> :filter::jsonb """, nativeQuery = true) List<Product> findByAttributeFilter(@Param("filter") String jsonFilter); // Usage: findByAttributeFilter("{\"color\": \"red\"}") // Full-text search inside JSONB @Query(value = """ SELECT * FROM products WHERE attributes::text ILIKE :keyword """, nativeQuery = true) List<Product> searchInAttributes(@Param("keyword") String keyword); }
Without indexes, JSONB queries require full table scans. PostgreSQL supports several JSONB index types:
-- GIN index: enables efficient @> (contains) operator -- Best for: checking if JSON contains a value, full-document search CREATE INDEX CONCURRENTLY idx_products_attributes_gin ON products USING GIN (attributes); -- Expression index on a specific JSON path -- Best for: querying a specific field frequently CREATE INDEX CONCURRENTLY idx_products_brand ON products ((attributes->>'brand')); -- Partial expression index for high-selectivity queries CREATE INDEX CONCURRENTLY idx_products_available_brand ON products ((attributes->>'brand')) WHERE (attributes->>'available')::boolean = true; -- Verify the index is used EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM products WHERE attributes @> '{"brand": "Samsung"}'::jsonb; -- Should show: "Bitmap Index Scan on idx_products_attributes_gin"
// Update the entire attributes map @Transactional public void updateAttributes(Long productId, Map<String, Object> newAttributes) { Product product = productRepo.findById(productId).orElseThrow(); product.setAttributes(newAttributes); productRepo.save(product); // Hibernate dirty-checking handles the update } // Merge — update only specific keys without overwriting others @Modifying @Query(value = """ UPDATE products SET attributes = attributes || :newFields::jsonb WHERE id = :id """, nativeQuery = true) void mergeAttributes(@Param("id") Long id, @Param("newFields") String newFieldsJson); // Remove a key @Modifying @Query(value = """ UPDATE products SET attributes = attributes - :key WHERE id = :id """, nativeQuery = true) void removeAttributeKey(@Param("id") Long id, @Param("key") String key);
JSONB is perfect for audit logs that need to capture arbitrary before/after state:
@Entity @Table(name = "audit_log") public class AuditLog { @Id @GeneratedValue private Long id; private String entityType; // "Order", "Product", "User" private Long entityId; private String action; // "CREATE", "UPDATE", "DELETE" private String changedBy; private Instant changedAt; @Type(JsonBinaryType.class) @Column(columnDefinition = "jsonb") private Map<String, Object> before; // State before change @Type(JsonBinaryType.class) @Column(columnDefinition = "jsonb") private Map<String, Object> after; // State after change } // Query: find all changes to a specific field @Query(value = """ SELECT * FROM audit_log WHERE entity_type = :type AND entity_id = :id AND (before->>'status' != after->>'status') -- Only status changes ORDER BY changed_at DESC """, nativeQuery = true) List<AuditLog> findStatusChanges(@Param("type") String type, @Param("id") Long id);
-- V3__add_product_attributes.sql ALTER TABLE products ADD COLUMN attributes JSONB NOT NULL DEFAULT '{}'; -- Backfill existing products with default attributes UPDATE products SET attributes = jsonb_build_object( 'brand', 'Unknown', 'available', true, 'tags', '[]'::jsonb ) WHERE attributes = '{}'; -- Add GIN index CREATE INDEX CONCURRENTLY idx_products_attributes_gin ON products USING GIN (attributes);
@> queries-> instead of ->> for comparisons — -> returns JSON (e.g., "red"); ->> returns text (red); comparing with = requires text, so use ->>PostgreSQL JSONB with Spring Boot and Hypersistence Utils gives you flexible semi-structured storage without a separate document database. Use it for variable-schema data (product attributes, user preferences, audit logs), add GIN indexes for @> queries and expression indexes for specific path queries, and keep frequently-filtered fields as relational columns. The combination of relational and document storage in one database is often the pragmatic sweet spot.
JSONB queries can hide N+1 patterns and missing indexes just like relational queries. JOptimize flags data access issues in your Spring Boot repositories.
Flexible storage. No performance compromise.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.