intermediate~4h

JPQL, Native Queries, Specifications & Criteria API

Four distinct tools for querying beyond what a derived method name can express, each suited to a different problem shape — from portable entity-oriented JPQL to fully dynamic, runtime-built Specifications.

Learning objectives

  • Choose the right query tool (JPQL, native SQL, Specification, Criteria API) for a given problem shape.
  • Build a dynamic, conditional query using Specifications that a static JPQL string couldn't express.
  • Explain why native queries sacrifice portability and how result mapping differs from JPQL.

Derived query methods (the previous chapter) only get you so far. The moment a query needs conditions decided at RUNTIME — not fixed at compile time — you need one of the tools this chapter covers.

📖 Story

Imagine building a product search page: "find products, OPTIONALLY filtered by category, OPTIONALLY filtered by price range, OPTIONALLY filtered by in-stock status" — where a user might supply any combination of filters, or none at all.

A derived query method literally cannot express this. Its method name is fixed at compile time — it has to list every condition it will ever check. You can't write findByOptionallyCategoryAndOptionallyPriceRange. This is exactly the gap Specifications exist to fill:

public class ProductSpecifications { public static Specification<Product> hasCategory(String category) { return (root, query, cb) -> category == null ? null : cb.equal(root.get("category"), category); } public static Specification<Product> priceBelow(BigDecimal max) { return (root, query, cb) -> max == null ? null : cb.lessThanOrEqualTo(root.get("price"), max); } } // Building the query at RUNTIME, based on which filters were actually supplied: Specification<Product> spec = Specification .where(ProductSpecifications.hasCategory(requestedCategory)) .and(ProductSpecifications.priceBelow(requestedMaxPrice)); List<Product> results = productRepository.findAll(spec);

If requestedCategory is null, that condition is simply skipped — no WHERE category = NULL nonsense, no giant hand-written string of ORs. The query genuinely adapts to whichever filters the user actually gave you.

JPQL — an object-oriented query language, syntactically close to SQL, but operating on entity/field names rather than table/column names, portable across JPA providers. Native query — raw SQL, executed directly, bypassing JPQL when you need a database-specific feature. Specification — a composable predicate object, combinable with .and()/.or(), for building queries whose conditions are decided at runtime. Criteria API — JPA's own fully type-safe, programmatic way to build a query using Java method calls instead of a string.

Let's see all four tools side by side, using this chapter's product-search example.

JPQL — for fixed, known query shapes

@Query("SELECT p FROM Product p WHERE p.category = :category") List<Product> findByCategory(@Param("category") String category);

Reads almost like SQL, but Product/p.category are entity/field names, not table/column names — Hibernate translates this to real SQL for whichever database you're running.

Native query — when you need database-specific SQL

@Query(value = "SELECT * FROM products WHERE data @> :jsonFilter::jsonb", nativeQuery = true) List<Product> findByJsonAttribute(@Param("jsonFilter") String jsonFilter);

That @> operator is PostgreSQL-specific JSONB syntax — JPQL has no way to express it. A native query drops down to raw SQL specifically for cases like this.

Specifications — this chapter's opening example, in full

Already shown above — the key insight is that each Specification is just a small function returning a predicate (or null to skip that condition entirely), and .and()/.or() compose them together at runtime.

Criteria API — the same idea, fully type-checked

CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Product> query = cb.createQuery(Product.class); Root<Product> root = query.from(Product.class); query.where(cb.equal(root.get("category"), "electronics")); List<Product> results = entityManager.createQuery(query).getResultList();

Notice root.get("category") — if you typo this as root.get("categroy"), it fails at COMPILE time with the generated metamodel classes, not at runtime like a JPQL string would. The cost is obvious: this is a lot more code than the Specification version above, for the same result.

JPQLNative SQLSpecificationCriteria API
PortableYesNoYesYes
Dynamic/conditionalAwkwardAwkwardNatural — this chapter's exampleNatural, verbose
Type-safetyString-basedString-basedPartialFull compile-time

JPQL queries are parsed by Hibernate into an internal query tree, then translated into the actual SQL dialect for your configured database — the same JPQL string produces correctly different SQL against PostgreSQL versus MySQL. Specifications defer actual predicate construction until execution time — Spring Data's JpaSpecificationExecutor accepts a Specification and internally uses the Criteria API underneath to build the final query, meaning Specifications are really a friendlier, composable wrapper over the exact same Criteria API mechanism shown directly in this chapter's Concept Overview.

  • This chapter's product search/filter page (category, price range, in-stock, any combination) is the textbook real-world case for Specifications.
  • A reporting query using a PostgreSQL-specific JSON operator that JPQL simply cannot express is a real, common reason to drop to a native query, exactly like this chapter's @> example.
  • An admin dashboard's dynamic filter builder, letting an admin combine arbitrary criteria through a UI, is commonly implemented by translating the UI's selected filters directly into a combined Specification at runtime.
  • Default to JPQL for straightforward, fixed-shape queries — most readable, fully portable.
  • Reach for Specifications specifically when conditions are genuinely dynamic/optional at runtime — this chapter's product-search example is exactly that shape of problem.
  • Use native queries deliberately and sparingly, for the specific cases where a database feature genuinely isn't expressible in JPQL, and document why.
  • Reserve the Criteria API for cases needing genuine compile-time type safety — for most teams, Specifications cover the same use case with less code.

⚠️ Why this keeps happening

All four tools eventually produce a SQL query against your entities, so it's easy to reach for the wrong one out of habit rather than matching the tool to the problem shape.

  • Building a dynamic filter query as one giant JPQL string with WHERE (:filter1 IS NULL OR field1 = :filter1) AND ... for every combination — technically works, but becomes unreadable past a few optional filters; this chapter's Specification example expresses the same intent far more cleanly.
  • Reaching for the Criteria API by default, assuming "type-safe" always means "better," without weighing the real verbosity cost against Specifications' friendlier equivalent.
  • Forgetting native queries often need explicit result mapping back to an entity or DTO, unlike JPQL, which maps automatically.

JPQL and native queries have essentially the same execution performance once translated to SQL. Specifications built from many small predicates (like this chapter's hasCategory/priceBelow) have negligible overhead over an equivalent hand-written JPQL query — the composition happens once at query-build time, not per row.

Both JPQL and native queries are vulnerable to injection if built via string concatenation of user input — always use :namedParameter binding, exactly as strictly as a raw JDBC PreparedStatement; never concatenate user input into either query type.

Log the actual generated SQL for Specification-built queries specifically — since the final query is assembled dynamically at runtime from composed predicates (like this chapter's example), it's the only reliable way to confirm the combined query matches your intent for a given combination of filters.

Maintain the same code-review rigor for native queries as for any raw SQL elsewhere in the codebase — since they bypass JPQL's provider-neutral abstraction, they deserve explicit review for correctness and index usage.

  1. Write this chapter's product-search filter three ways: as a derived method (notice it can't handle "optional" conditions cleanly), as @Query JPQL, and as a native query.
  2. Build the exact hasCategory/priceBelow Specifications from this chapter and combine them with .and(), confirming the generated WHERE clause correctly omits a condition when its filter isn't supplied.
  3. Write the equivalent of one of your Specifications using the Criteria API directly, and compare the verbosity of both approaches.

✓ Quick recap

  • JPQL is entity-oriented and portable; native queries are raw SQL, powerful but database-specific.
  • Specifications and the Criteria API both build dynamic, conditional queries at runtime — Specifications are a friendlier wrapper over the same underlying Criteria API mechanism.
  • Match the tool to the problem: fixed queries → JPQL; a database-specific feature → native; genuinely dynamic/optional filters (this chapter's product search) → Specifications.

Want a visual for this concept?

Generate a diagram tailored to “JPQL, Native Queries, Specifications & Criteria API” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Projections, Pagination & Sorting← Back to all Spring Data JPA & Hibernate Mastery chapters