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.Nullsbelow is a genuine 3.2 addition, verified withjavapagainstjakarta.persistence-api-3.2.0.jarrather 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.
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.
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 theTreeSetcan be built. Reach for@OrderBywhenever the sort key is a plain column and the collection can be sizable; save@SortNatural/@SortComparatorfor genuinely small collections or orderings SQL cannot express.
- A
SortedMaptakes the same@SortNatural/@SortComparatorpair as aSortedSet, 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 BYtakes a column identifier, not a value, so it cannot be bound as a query parameter the way aWHEREvalue 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")));
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.
- Jakarta Persistence 3.2 specification, §7.2 covers
NullsandOrderin full — jakarta.ee/specifications/persistence/3.2.
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 inORDER BY, not aComparator.
Further reading
- hibernate-demo companion repository — chapter 22 and all six tests behind this post
- Hibernate 7 Pagination
- Hibernate 7 Criteria Queries
- Hibernate Query Language (HQL) in Hibernate 7
- Hibernate 7 Aggregate Functions — ordering grouped results with ORDER BY
- Jakarta Persistence 3.2 specification
No Comments yet!