Spring Boot
Native Queries
Native queries execute raw SQL directly against the database, bypassing JPQL translation. They are the right tool when you need database-specific SQL features, complex window functions, CTEs, full-text search, or when an ORM-generated query cannot match a hand-tuned SQL statement. Spring Data JPA supports native queries through @Query(nativeQuery = true) and through the EntityManager API.
Native Queries with @Query
Setting nativeQuery = true on @Query tells Spring Data JPA to pass the query string directly to JDBC without JPQL translation. The query uses actual table and column names, not entity class and field names.
Java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// ── Basic native query — returns mapped entities: ─────────────────
@Query(value = "SELECT * FROM users WHERE email = :email",
nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
// ── Native query with multiple conditions: ────────────────────────
@Query(value = """
SELECT * FROM users
WHERE role = :role
AND active = true
ORDER BY created_at DESC
""",
nativeQuery = true)
List<User> findActiveByRole(@Param("role") String role);
// Note: enum values must be passed as strings in native queries
// ── Database-specific SQL — window function (not in JPQL): ────────
@Query(value = """
SELECT u.*,
RANK() OVER (PARTITION BY u.role ORDER BY u.created_at) AS rank
FROM users u
WHERE u.active = true
""",
nativeQuery = true)
List<Object[]> findUsersWithRank();
// ── CTE (Common Table Expression): ────────────────────────────────
@Query(value = """
WITH recent_orders AS (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY customer_id
)
SELECT u.* FROM users u
JOIN recent_orders ro ON ro.customer_id = u.id
WHERE ro.order_count >= :minOrders
""",
nativeQuery = true)
List<User> findActiveCustomers(@Param("minOrders") int minOrders);
// ── Full-text search (PostgreSQL): ────────────────────────────────
@Query(value = """
SELECT * FROM products
WHERE to_tsvector('english', name || ' ' || description)
@@ plainto_tsquery('english', :query)
ORDER BY ts_rank(
to_tsvector('english', name || ' ' || description),
plainto_tsquery('english', :query)
) DESC
""",
nativeQuery = true)
List<Product> fullTextSearch(@Param("query") String query);
}Native Queries with Pagination
Native queries with pagination require an explicit countQuery. Without it, Spring Data cannot determine the total element count needed to build the Page object. The countQuery uses the same table and column names as the main query.
Java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// ── Native query with pagination — countQuery is required: ────────
@Query(
value = """
SELECT o.* FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = :country
AND o.status = :status
ORDER BY o.created_at DESC
""",
countQuery = """
SELECT COUNT(*) FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = :country
AND o.status = :status
""",
nativeQuery = true
)
Page<Order> findByCountryAndStatus(
@Param("country") String country,
@Param("status") String status,
Pageable pageable);
// ── Native query with complex pagination: ─────────────────────────
@Query(
value = """
SELECT p.*,
COALESCE(AVG(r.rating), 0) AS avg_rating,
COUNT(r.id) AS review_count
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
WHERE p.category_id = :categoryId
GROUP BY p.id
HAVING COUNT(r.id) >= :minReviews
ORDER BY avg_rating DESC, p.name ASC
""",
countQuery = """
SELECT COUNT(*) FROM (
SELECT p.id FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
WHERE p.category_id = :categoryId
GROUP BY p.id
HAVING COUNT(r.id) >= :minReviews
) subquery
""",
nativeQuery = true
)
Page<Object[]> findTopRatedProducts(
@Param("categoryId") Long categoryId,
@Param("minReviews") int minReviews,
Pageable pageable);
// ── IMPORTANT: Sort in native queries ─────────────────────────────
// Passing a Pageable with Sort to a native query is unreliable —
// Spring Data maps Sort field names from entity fields to column names
// but native queries use column names directly.
// For native queries with dynamic sort: use a fixed ORDER BY clause,
// or build the query string dynamically in a custom repository implementation.
}Projections with Native Queries
Native queries can return interface projections or class-based DTOs instead of full entities. Interface projections use Spring Data's proxy mechanism; class-based projections use constructor expressions or @SqlResultSetMapping.
Java
// ── Interface projection — method names must match column aliases: ────
public interface UserSummary {
Long getId();
String getName();
String getEmail();
Long getOrderCount();
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Column aliases must exactly match the getter names (case-insensitive):
@Query(value = """
SELECT u.id AS id,
u.name AS name,
u.email AS email,
COUNT(o.id) AS orderCount
FROM users u
LEFT JOIN orders o ON o.customer_id = u.id
GROUP BY u.id, u.name, u.email
""",
nativeQuery = true)
List<UserSummary> findUserSummaries();
// Spring Data generates a proxy implementing UserSummary —
// each getter reads the corresponding alias from the result set.
// ── Nested projection (Hibernate 6+): ─────────────────────────────
public interface OrderSummary {
Long getId();
BigDecimal getTotal();
CustomerInfo getCustomer();
interface CustomerInfo {
Long getId();
String getName();
}
}
// Requires Hibernate-specific @NestedConfigurationProperty — use
// class-based DTOs with @SqlResultSetMapping for complex nested results.
}
// ── Class-based DTO via constructor expression in native query: ────────
public record ProductStats(
Long productId,
String productName,
long reviewCount,
double avgRating
) { }
// Use @SqlResultSetMapping for class-based DTOs with native queries:
@Entity
@SqlResultSetMapping(
name = "ProductStatsMapping",
classes = @ConstructorResult(
targetClass = ProductStats.class,
columns = {
@ColumnResult(name = "product_id", type = Long.class),
@ColumnResult(name = "product_name", type = String.class),
@ColumnResult(name = "review_count", type = Long.class),
@ColumnResult(name = "avg_rating", type = Double.class)
}
)
)
@NamedNativeQuery(
name = "Product.findStats",
query = """
SELECT p.id AS product_id, p.name AS product_name,
COUNT(r.id) AS review_count, AVG(r.rating) AS avg_rating
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
GROUP BY p.id, p.name
""",
resultSetMapping = "ProductStatsMapping"
)
public class Product { }Native Modifying Queries
Native UPDATE and DELETE queries use @Modifying exactly like JPQL modifying queries. They execute directly against the database, bypassing the persistence context — use clearAutomatically to prevent stale entities after bulk operations.
Java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// ── Native bulk UPDATE: ────────────────────────────────────────────
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query(value = "UPDATE users SET active = false WHERE last_login_at < :cutoff",
nativeQuery = true)
int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
// ── Native bulk DELETE: ────────────────────────────────────────────
@Modifying(clearAutomatically = true)
@Transactional
@Query(value = """
DELETE FROM users
WHERE active = false
AND created_at < :cutoff
""",
nativeQuery = true)
int deleteOldInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
// ── Database-specific UPSERT (PostgreSQL ON CONFLICT): ────────────
@Modifying
@Transactional
@Query(value = """
INSERT INTO user_settings (user_id, setting_key, setting_value)
VALUES (:userId, :key, :value)
ON CONFLICT (user_id, setting_key)
DO UPDATE SET setting_value = EXCLUDED.setting_value,
updated_at = NOW()
""",
nativeQuery = true)
void upsertSetting(
@Param("userId") Long userId,
@Param("key") String key,
@Param("value") String value);
// ── MySQL INSERT ON DUPLICATE KEY: ────────────────────────────────
@Modifying
@Transactional
@Query(value = """
INSERT INTO page_views (page_url, view_count, last_viewed_at)
VALUES (:url, 1, NOW())
ON DUPLICATE KEY UPDATE
view_count = view_count + 1,
last_viewed_at = NOW()
""",
nativeQuery = true)
void incrementPageView(@Param("url") String url);
}EntityManager for Native Queries
The EntityManager API provides full control over native query execution — useful for dynamic queries, stored procedures, and batch processing where the Spring Data @Query annotation is insufficient.
Java
@Repository
@RequiredArgsConstructor
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {
private final EntityManager em;
// ── Dynamic native query: ─────────────────────────────────────────
@Override
public List<Product> searchProducts(ProductFilter filter) {
StringBuilder sql = new StringBuilder(
"SELECT * FROM products WHERE 1=1");
if (filter.getName() != null)
sql.append(" AND LOWER(name) LIKE LOWER(:name)");
if (filter.getMinPrice() != null)
sql.append(" AND price >= :minPrice");
if (filter.getCategory() != null)
sql.append(" AND category_id = :categoryId");
sql.append(" ORDER BY name LIMIT :limit OFFSET :offset");
Query query = em.createNativeQuery(sql.toString(), Product.class);
if (filter.getName() != null)
query.setParameter("name", "%" + filter.getName() + "%");
if (filter.getMinPrice() != null)
query.setParameter("minPrice", filter.getMinPrice());
if (filter.getCategory() != null)
query.setParameter("categoryId", filter.getCategory());
query.setParameter("limit", filter.getPageSize());
query.setParameter("offset", filter.getOffset());
return query.getResultList();
}
// ── Stored procedure: ─────────────────────────────────────────────
@Override
@Transactional
public void recalculateUserStats(Long userId) {
em.createNativeQuery("CALL recalculate_user_stats(:userId)")
.setParameter("userId", userId)
.executeUpdate();
}
// ── Batch insert with JDBC-level control: ─────────────────────────
@Override
@Transactional
public void bulkInsert(List<AuditLog> logs) {
Session session = em.unwrap(Session.class);
session.doWork(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO audit_logs (user_id, action, created_at) " +
"VALUES (?, ?, ?)");
for (int i = 0; i < logs.size(); i++) {
ps.setLong(1, logs.get(i).getUserId());
ps.setString(2, logs.get(i).getAction());
ps.setTimestamp(3, Timestamp.valueOf(logs.get(i).getCreatedAt()));
ps.addBatch();
if (i % 50 == 0) ps.executeBatch();
}
ps.executeBatch();
});
}
}Native vs JPQL — Decision Guide
Native queries and JPQL each have clear strengths. Choose based on what the query requires, not on familiarity with SQL.
Shell
# ── Use JPQL when: ───────────────────────────────────────────────────
# - The query is expressible in standard SQL constructs
# - Database portability matters (switching MySQL ↔ PostgreSQL)
# - You want Hibernate to manage JOIN and alias mapping
# - The query uses entity associations (JOIN FETCH, navigation)
# - You want compile-time validation via @NamedQuery
# Example — JPQL is better here:
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") Order.Status status);
# ── Use native SQL when: ──────────────────────────────────────────────
# - The query uses database-specific features:
# Window functions (ROW_NUMBER, RANK, LEAD, LAG)
# CTEs (WITH ... AS)
# UPSERT (ON CONFLICT, ON DUPLICATE KEY)
# Full-text search (PostgreSQL tsvector, MySQL FULLTEXT)
# JSON operators (PostgreSQL ->>, MySQL JSON_EXTRACT)
# Lateral joins, recursive CTEs
# - Hibernate's generated SQL is provably slower than a hand-tuned query
# - You are calling stored procedures or database functions
# - Bulk operations that must bypass the persistence context entirely
# Example — native SQL is better here:
@Query(value = """
SELECT u.*,
NTILE(4) OVER (ORDER BY u.total_spent DESC) AS spending_quartile
FROM users u
WHERE u.active = true
""", nativeQuery = true)
List<Object[]> findUsersWithSpendingQuartile();
# ── Pitfalls to avoid: ────────────────────────────────────────────────
# - Native queries bypass Hibernate's entity cache — stale data risk
# - Column aliases must match projection getter names exactly
# - Enum parameters must be passed as strings (not enum instances)
# - Native pagination with Sort is unreliable — use fixed ORDER BY
# - Always use @Modifying(clearAutomatically=true) for bulk operations