Back to Blog
postgresqljsonbspring-bootjpadatabasejava

PostgreSQL JSONB with Spring Boot: Store and Query Semi-Structured Data (2026)

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.

J

JOptimize Team

May 30, 2026· 9 min read

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.


When to Use JSONB (and When Not To)

JSONB makes sense when:

  • Schema varies per row — product attributes differ by category; storing all possible columns as nullable is ugly
  • Data is read as a blob — you often retrieve the entire JSON without querying inside it
  • Schema evolves frequently — adding fields to a JSON column requires no migration; adding a relational column does
  • Semi-structured audit data — capturing arbitrary before/after state for an audit log

JSONB is the wrong choice when:

  • You query inside the JSON frequently — relational columns with indexes outperform JSONB for high-cardinality queries
  • You need referential integrity — foreign keys don't work on JSON values
  • The data is actually structured — if every product has the same 10 attributes, use columns

Setting Up JSONB with Spring Boot

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 ) {}

Querying JSONB with Native SQL

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); }

Indexing JSONB for Performance

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"

Updating JSONB Fields

// 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);

Audit Log Pattern with JSONB

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);

Flyway Migration for JSONB Columns

-- 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);

Common Mistakes to Avoid

  • No GIN index on queried JSONB columns — a JSONB column without indexes requires a full table scan for every query; always add a GIN index for @> queries
  • Storing everything in JSONB — don't put customer email, order status, or any field you filter on frequently into JSONB; use relational columns for these
  • Using -> instead of ->> for comparisons-> returns JSON (e.g., "red"); ->> returns text (red); comparing with = requires text, so use ->>
  • Not testing JSONB queries with EXPLAIN ANALYZE — JSONB query plans are less predictable than relational queries; always verify indexes are used

Summary

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.


Analyze Your PostgreSQL Queries with JOptimize

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.