Skip to main content

Sorting in Hibernate 7: @OrderBy, Criteria Order, Null Precedence, and the Dynamic-Sort Injection Trap

Sorting a List in Java after fetching it works until the table gets large, and letting a caller pick the sort column by string concatenation is an HQL injection risk. This guide covers Hibernate 7’s @OrderBy, @SortNatural/@SortComparator, a whitelist fix for dynamic sorting, Criteria API Order across joins, JPA 3.2’s Nulls precedence, and case-insensitive sorting, verified against a real project.

Users notice unsorted data immediately — a product list that reshuffles on every page load, a playlist whose songs play in an arbitrary order. The instinct once you have a List back from Hibernate is to sort it in Java. That works until the list has ten thousand items and you are paying to fetch, hydrate, and discard all of them just to reorder a handful the user will actually see.

This is the beginner-through-expert guide to sorting in Hibernate 7 — @OrderBy, @SortNatural/@SortComparator, dynamic HQL sorting and the injection trap it hides, Criteria API Order across a join, JPA 3.2’s Nulls precedence, and case-insensitive sorting — built and verified against a real Spring Boot project. Every code block links to the file it came from and every console line was copied from a real, committed test run in the hibernate-demo companion repository.

Versions used in this post. Hibernate ORM 7.4.5.Final, Jakarta Persistence 3.2, Spring Boot 4.1.1, JDK 25. jakarta.persistence.criteria.Nulls below is a genuine 3.2 addition, verified with javap against jakarta.persistence-api-3.2.0.jar rather than assumed from documentation prose.

Why sorting belongs in the query, not in a Java Comparator

Loading every row and calling Collections.sort() looks harmless with twenty test rows. It stops being harmless the moment the table has real volume: every row Hibernate does not need for display still gets fetched over the network, allocated as an object, and registered in the persistence context, purely so a Comparator can throw most of that work away. The database already has an index it can walk in order — asking it to sort is strictly cheaper than sorting after the fact in the JVM.

The second failure mode is subtler: letting a caller pick which column to sort by. A search screen with a “sort by” dropdown is completely ordinary, but building the query string by concatenating whatever the caller sent is an open door for HQL injection — covered concretely further down, with a whitelist that closes it.

The mental model: @OrderBy sorts by property name, not column name

A common assumption is that @OrderBy‘s string argument is a fragment of raw SQL. It is not — Hibernate resolves it against the entity’s own metamodel, so the string you write is a Java property name, and Hibernate translates that to whichever column it is actually mapped to.

@OrderBy resolves the PROPERTY name, then emits the COLUMN name @OrderBy(“title asc”) a Java property name resolved against Song.title entity metamodel property mapped via @Column(name = “song_title”) order by song_title the real generated SQL

The column being different from the property is the point of the diagram — if @OrderBy were raw SQL, "title asc" would fail outright, since no title column exists. It succeeds because Hibernate never sees it as SQL in the first place.

The smallest thing that works: @OrderBy on a collection

@Column(name = "song_title")
private String title;
@OneToMany(mappedBy = "playlist")
@OrderBy("title asc")
private List<Song> songs;

Playlist.java deliberately maps title to a differently-named column so the test cannot pass by accident. SortingTest.java loads the collection and prints the generated SQL alongside the order it came back in:

select s1_0.playlist_id,s1_0.id,s1_0.artist,s1_0.rating,s1_0.song_title from song s1_0 where s1_0.playlist_id=? order by s1_0.song_title
RESULT[sorting-orderby-property-name]: @OrderBy("title asc") on the songs collection, where the entity property is 'title' but the mapped column is 'song_title' -- loaded order: [Alpha, Mike, Zulu] -- Hibernate resolved the PROPERTY name to the right column itself.

Full transcript: 22-orderby-property-name.txt. @OrderBy with no argument at all defaults to the collection’s primary key, ascending — easy to miss when a collection happens to look correctly ordered by luck and then stops after a schema change. Full chapter: companion repo chapter 22.

What @SortNatural and @SortComparator actually do: sort in the JVM, not the database

@OrderBy changes the generated SQL. @SortNatural and @SortComparator do not — they tell Hibernate to load the collection into a real java.util.TreeSet and let the Comparable or Comparator do the ordering in memory, after the rows already arrived unsorted from the database.

@SortNatural
private SortedSet<String> tags = new TreeSet<>();

@SortComparator(LengthThenAlphaComparator.class)
private SortedSet<String> genres = new TreeSet<>(new LengthThenAlphaComparator());

Playlist.java, LengthThenAlphaComparator.java — the comparator class needs a no-arg constructor, since Hibernate instantiates it by reflection.

@SortNatural / @SortComparator sort AFTER the rows arrive rock, live, acoustic rows from the database, any order in the JVM new TreeSet<>(…) acoustic, live, rock Contrast with the previous diagram: @OrderBy changes the SQL the database runs. @SortNatural and @SortComparator never touch the SQL — the ORDER BY column here is nothing.
RESULT[sorting-natural-and-comparator]: @SortNatural tags=[acoustic, live, rock] (plain alphabetical) | @SortComparator genres=[pop, folk, jazz-fusion] (shortest name first, alphabetical tiebreaker) -- both are real java.util.TreeSet instances rebuilt in memory on load, not an ORDER BY on the collection table.

Full test and transcript: SortingTest.java, 22-sort-natural-and-comparator.txt.

When this matters. For a small element collection, in-memory sorting is invisible overhead. For a large one, it is the same memory-first cost the rest of this post argues against — every element still has to be fetched before the TreeSet can be built. Reach for @OrderBy whenever the sort key is a plain column and the collection can be sizable; save @SortNatural/@SortComparator for genuinely small collections or orderings SQL cannot express.
  • A SortedMap takes the same @SortNatural/@SortComparator pair as a SortedSet, sorting by key instead of by element — see companion repo chapter 22.

Dynamic sorting from user input: the injection risk, and the whitelist that closes it

A “sort by” dropdown means the sort column is decided at request time. The naive implementation concatenates the caller’s string straight into the query:

String hql = "SELECT p FROM Product p ORDER BY p." + sortBy + " " + direction;

That hands anyone who can influence sortBy the ability to inject arbitrary HQL — a path onto an unrelated entity, a nested expression, or a string crafted purely to break the query. The fix is a whitelist checked before the string ever reaches a query, not an attempt to escape or sanitize it:

private static final Set<String> ALLOWED = Set.of("title", "artist", "rating");

public static String toHqlPropertyOrThrow(String requested) {
    if (!ALLOWED.contains(requested)) {
        throw new IllegalArgumentException(...);
    }
    return requested;
}

SongSortField.java. SortingTest.java feeds it a string that is not a real property at all and confirms it never reaches the query engine, then confirms a legitimate field still works:

RESULT[sorting-dynamic-injection-guard]: whitelist rejected 'id) --' with IllegalArgumentException ("'id) --' is not a sortable field; allowed values are [artist, title, rating]") before it ever reached the query engine | whitelisted field 'artist' produced order by s.artist -- result: [Alpha Band, Zeta Band] -- the string never touches the HQL unless it's one of the three known-safe property names.

Full transcript: 22-dynamic-injection-guard.txt.

The whitelist is not optional hardening. There is no partial version of this fix — either the sort column comes from a fixed, known-safe set the code controls, or a caller can eventually make the query say something the code never intended. Parameter binding (setParameter) does not help here at all: ORDER BY takes a column identifier, not a value, so it cannot be bound as a query parameter the way a WHERE value can.

Criteria API Order across a join, not just the query root

Join<Song, Playlist> playlistJoin = root.join("playlist");
cq.select(playlistJoin.get("name"))
        .orderBy(cb.asc(playlistJoin.get("name")));

SortingTest.java:

RESULT[sorting-criteria-order-join]: Criteria root.join("playlist") ordered by the JOINED entity's name -- [A-Playlist, B-Playlist] -- proves Order in the Criteria API is not limited to the root entity's own columns.

Full transcript: 22-criteria-order-join.txt. For the rest of the Criteria API — predicates, dynamic query construction — see Hibernate 7 Criteria Queries.

Controlling where NULLs land with jakarta.persistence.criteria.Nulls

Different databases default nulls to different ends of an ASC/DESC sort, which makes cross-database behavior inconsistent unless it is made explicit. Jakarta Persistence 3.2 — Hibernate 7’s baseline — added exactly that: a Nulls enum (FIRST, LAST, NONE) and matching CriteriaBuilder.asc(Expression, Nulls)/.desc(...) overloads, confirmed with javap against jakarta.persistence-api-3.2.0.jar rather than assumed from prose:

cq.orderBy(cb.asc(root.get("rating"), Nulls.LAST), cb.asc(root.get("title")));
RESULT[sorting-null-precedence]: cb.asc(root.get("rating"), Nulls.LAST) -- [Rated Low, Rated High, Unrated One, Unrated Two] -- both unrated songs sort after every rated song regardless of what H2's own default null-ordering for ASC would otherwise do, because Nulls.LAST is explicit in the generated SQL's ORDER BY, not left to the dialect's default.

Full test and transcript: SortingTest.java, 22-null-precedence.txt.

Case-insensitive sorting via cb.lower()

cq.orderBy(cb.asc(cb.lower(root.get("title"))));
RESULT[sorting-case-insensitive]: cb.lower(root.get("title")) ascending -- [Apple, banana, cherry] -- 'Apple' sorts before 'banana' despite the capital A, because the comparison happens on the lower-cased value, not the raw column.

Full test and transcript: SortingTest.java, 22-case-insensitive.txt. The comparison happens on a value the database computes on the fly — without an expression-level index on lower(title), this sort cannot use a plain index on title and may cost more on a very large table than an application-level default collation would.

What the defaults do not do: sorting, pagination, and JOIN FETCH interact

None of the sections above paginate their results, and that is deliberate: combining ORDER BY with a JOIN FETCH over a to-many association and a page limit is where Hibernate’s pagination can silently fall back to sorting in application memory — but only when the sort key lives on the fetched collection itself, not the root entity. That distinction, verified against the real jar rather than assumed, is covered in full in the pagination post.

Should you always sort at the database? Yes, with the same one exception as aggregation: if the ordering logic is genuinely too irregular for SQL — a weighted relevance score with business rules that change per tenant — fetch a bounded, already-filtered result set and sort that in Java. Anything expressible as a plain column, a joined column, or a database function belongs in ORDER BY, not a Comparator.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.