Spring Boot
PostgreSQL Integration
PostgreSQL is the preferred relational database for Spring Boot applications requiring advanced SQL features — window functions, CTEs, JSONB, full-text search, array types, and advisory locks. Spring Boot auto-configures everything from a single datasource URL. This entry covers setup, PostgreSQL-specific JPA mappings, advanced query features, and testing with Testcontainers.
Dependencies and Setup
The PostgreSQL JDBC driver and spring-boot-starter-data-jpa are all that is needed. Hibernate auto-detects the PostgreSQL dialect from Spring Boot 3 onwards.
XML
<!-- pom.xml: -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
# ── application.yml: ──────────────────────────────────────────────────
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: postgres
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 20000
idle-timeout: 300000
max-lifetime: 600000 # PostgreSQL default idle timeout is 10min
keepalive-time: 60000
pool-name: myapp-pg-pool
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
show-sql: false
properties:
hibernate:
# Hibernate 6 auto-detects PostgreSQLDialect — explicit only if needed:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
jdbc:
batch_size: 25
batch_versioned_data: true
default_batch_fetch_size: 25
# ── Flyway for schema management: ────────────────────────────────────
flyway:
enabled: true
locations: classpath:db/migration
validate-on-migrate: truePostgreSQL-Specific JPA Mappings
PostgreSQL supports JSONB, arrays, UUID primary keys, enums, and SERIAL/SEQUENCE generation. These map to JPA entities with specific column definitions and converters.
Java
@Entity
@Table(name = "products",
indexes = {
@Index(name = "idx_products_category", columnList = "category"),
@Index(name = "idx_products_tags", columnList = "tags")
})
public class Product {
// PostgreSQL UUID primary key — no AUTO_INCREMENT, no sequence needed:
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "uuid", updatable = false)
private UUID id;
@Column(nullable = false, length = 200)
private String name;
// PostgreSQL TEXT — no length limit:
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
// PostgreSQL JSONB — binary JSON with indexing support:
@Column(columnDefinition = "jsonb")
@Convert(converter = JsonbConverter.class)
private Map<String, Object> metadata;
// PostgreSQL array — mapped via custom type or converter:
@Column(columnDefinition = "text[]")
@Convert(converter = StringArrayConverter.class)
private List<String> tags;
// PostgreSQL native enum — requires CREATE TYPE in migration:
@Column(columnDefinition = "product_status")
@Enumerated(EnumType.STRING)
private ProductStatus status;
// TIMESTAMPTZ — timestamp with time zone:
@Column(nullable = false, updatable = false,
columnDefinition = "TIMESTAMPTZ")
private OffsetDateTime createdAt;
}
// ── JSONB converter: ──────────────────────────────────────────────────
@Converter
public class JsonbConverter
implements AttributeConverter<Map<String, Object>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, Object> map) {
try { return map == null ? null : mapper.writeValueAsString(map); }
catch (JsonProcessingException e) { throw new RuntimeException(e); }
}
@Override
public Map<String, Object> convertToEntityAttribute(String json) {
try { return json == null ? null : mapper.readValue(json, Map.class); }
catch (JsonProcessingException e) { throw new RuntimeException(e); }
}
}
// ── Flyway migration for PostgreSQL-specific DDL: ─────────────────────
-- V1__create_products_table.sql
CREATE TYPE product_status AS ENUM ('ACTIVE', 'INACTIVE', 'DISCONTINUED');
CREATE TABLE products (
id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL,
metadata JSONB,
tags TEXT[],
status product_status NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
CREATE INDEX idx_products_tags ON products USING GIN (tags);
CREATE INDEX idx_products_name_fts ON products
USING GIN (to_tsvector('english', name));PostgreSQL-Specific Native Queries
PostgreSQL's advanced SQL features — window functions, CTEs, JSONB operators, full-text search, UPSERT, and array operators — require native queries. These are the features that most justify choosing PostgreSQL over other databases.
Java
@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
// ── PostgreSQL UPSERT (ON CONFLICT DO UPDATE): ────────────────────
@Modifying
@Transactional
@Query(value = """
INSERT INTO products (id, name, price, status)
VALUES (:id, :name, :price, :status)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
price = EXCLUDED.price,
status = EXCLUDED.status,
updated_at = NOW()
""",
nativeQuery = true)
void upsert(@Param("id") UUID id,
@Param("name") String name,
@Param("price") BigDecimal price,
@Param("status") String status);
// ── PostgreSQL full-text search: ──────────────────────────────────
@Query(value = """
SELECT p.* FROM products p
WHERE to_tsvector('english', p.name || ' ' || COALESCE(p.description, ''))
@@ plainto_tsquery('english', :query)
ORDER BY ts_rank(
to_tsvector('english', p.name || ' ' || COALESCE(p.description, '')),
plainto_tsquery('english', :query)
) DESC
""",
nativeQuery = true)
List<Product> fullTextSearch(@Param("query") String query);
// ── JSONB operators: ──────────────────────────────────────────────
@Query(value = "SELECT * FROM products WHERE metadata ->> 'brand' = :brand",
nativeQuery = true)
List<Product> findByBrand(@Param("brand") String brand);
@Query(value = "SELECT * FROM products WHERE metadata @> :filter::jsonb",
nativeQuery = true)
List<Product> findByMetadataContaining(@Param("filter") String jsonFilter);
// ── Array operators: ──────────────────────────────────────────────
@Query(value = "SELECT * FROM products WHERE :tag = ANY(tags)",
nativeQuery = true)
List<Product> findByTag(@Param("tag") String tag);
@Query(value = "SELECT * FROM products WHERE tags && ARRAY[:tags]::text[]",
nativeQuery = true)
List<Product> findByAnyTag(@Param("tags") List<String> tags);
// ── Window functions: ─────────────────────────────────────────────
@Query(value = """
SELECT
p.*,
AVG(p.price) OVER (PARTITION BY p.category) AS category_avg_price,
RANK() OVER (PARTITION BY p.category ORDER BY p.price DESC)
AS price_rank_in_category,
LAG(p.price) OVER (ORDER BY p.created_at) AS prev_price
FROM products p
WHERE p.status = 'ACTIVE'
""",
nativeQuery = true)
List<Object[]> findWithWindowStats();
// ── Recursive CTE: ────────────────────────────────────────────────
@Query(value = """
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 0 AS depth
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.depth + 1
FROM categories c
JOIN category_tree ct ON ct.id = c.parent_id
)
SELECT * FROM category_tree ORDER BY depth, name
""",
nativeQuery = true)
List<Object[]> findCategoryTree();
}Sequences and ID Generation
PostgreSQL sequences are more efficient than IDENTITY for high-insert workloads because Hibernate can fetch a batch of IDs before inserting, enabling JDBC batch operations.
Java
// ── SEQUENCE strategy with allocationSize — enables batch inserts: ────
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "orders_seq")
@SequenceGenerator(
name = "orders_seq",
sequenceName = "orders_id_seq", // PostgreSQL sequence name
allocationSize = 50 // fetch 50 IDs per round trip
)
private Long id;
}
-- Flyway migration — create the sequence:
-- V2__create_orders_sequence.sql
CREATE SEQUENCE orders_id_seq
START WITH 1
INCREMENT BY 50 -- must match allocationSize in Java
NO MINVALUE
NO MAXVALUE
CACHE 50;
CREATE TABLE orders (
id BIGINT NOT NULL DEFAULT nextval('orders_id_seq'),
reference VARCHAR(50) NOT NULL UNIQUE,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);
// ── UUID with gen_random_uuid() — no sequence needed: ─────────────────
@Entity
@Table(name = "events")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "uuid", updatable = false, nullable = false)
private UUID id;
}
-- Flyway migration:
CREATE TABLE events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);Docker and Testcontainers
PostgreSQL in Docker for local development and Testcontainers for integration tests provide identical database behaviour between development, CI, and production.
yaml
# ── docker-compose.yml: ───────────────────────────────────────────────
services:
postgres:
image: postgres:16-alpine
container_name: myapp-postgres
environment:
POSTGRES_DB: mydb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppassword
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d
command: >
postgres
-c shared_preload_libraries=pg_stat_statements
-c pg_stat_statements.track=all
-c max_connections=100
volumes:
postgres_data:
# ── application-dev.yml: ──────────────────────────────────────────────
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: appuser
password: apppassword
jpa:
hibernate:
ddl-auto: create-drop
# ── Testcontainers integration test: ──────────────────────────────────
<!-- pom.xml: -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class ProductRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private ProductRepository productRepository;
@Test
void upsertCreatesAndUpdates() {
UUID id = UUID.randomUUID();
productRepository.upsert(id, "Widget", new BigDecimal("9.99"), "ACTIVE");
productRepository.upsert(id, "Widget Pro", new BigDecimal("19.99"), "ACTIVE");
Product found = productRepository.findById(id).orElseThrow();
assertThat(found.getName()).isEqualTo("Widget Pro");
}
}